Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
546a1510b2 | ||
|
|
455a91935d | ||
|
|
5ddb4dce3f | ||
|
|
6c3b10672c | ||
|
|
cb6b52b334 | ||
|
|
e8e50b70df | ||
|
|
fd130a3fbd | ||
|
|
7ec8601bd6 | ||
|
|
39ae54d682 | ||
|
|
bea542b310 | ||
|
|
e7a69d68df | ||
|
|
d67bf45527 | ||
|
|
72dc999108 | ||
|
|
14e2fa6893 | ||
|
|
2c105aceb8 | ||
|
|
1e0b0d949f | ||
|
|
c60addc528 | ||
|
|
38a7fcfedd | ||
|
|
e620264848 | ||
|
|
12e42427e2 | ||
|
|
ad137fbf9c | ||
|
|
da91152365 | ||
|
|
adc198b37b | ||
|
|
2608e136cb | ||
|
|
7f46ce1851 | ||
|
|
a4f8571f59 | ||
|
|
f319b662c5 | ||
|
|
37847c0061 | ||
|
|
a8abe2cdf1 | ||
|
|
ea66a9093c | ||
|
|
62216f2db6 | ||
|
|
e0c637b8ec |
@@ -0,0 +1,33 @@
|
||||
name: Companion Lint
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint Companion App
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: .github
|
||||
- uses: ./.github/actions/cache-checkout
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.3.0
|
||||
cache: false
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: companion
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run lint
|
||||
working-directory: companion
|
||||
run: bun run lint:all
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Companion Type Check
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: Type Check Companion App
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: .github
|
||||
- uses: ./.github/actions/cache-checkout
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.3.0
|
||||
cache: true
|
||||
cache-dependency-path: companion/bun.lock
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: companion
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
working-directory: companion
|
||||
run: bun run typecheck:all
|
||||
@@ -299,7 +299,7 @@ jobs:
|
||||
skip-install-if-cache-hit: "true"
|
||||
|
||||
type-check:
|
||||
name: Type check
|
||||
name: Type Checks
|
||||
needs: [prepare]
|
||||
if: ${{ needs.prepare.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/check-types.yml
|
||||
@@ -377,6 +377,20 @@ jobs:
|
||||
uses: ./.github/workflows/companion-build.yml
|
||||
secrets: inherit
|
||||
|
||||
typecheck-companion:
|
||||
name: Type Checks
|
||||
needs: [prepare]
|
||||
if: needs.prepare.outputs.has-companion == 'true'
|
||||
uses: ./.github/workflows/companion-typecheck.yml
|
||||
secrets: inherit
|
||||
|
||||
lint-companion:
|
||||
name: Linters
|
||||
needs: [prepare]
|
||||
if: needs.prepare.outputs.has-companion == 'true'
|
||||
uses: ./.github/workflows/companion-lint.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
name: Production builds
|
||||
needs: [prepare]
|
||||
@@ -457,6 +471,8 @@ jobs:
|
||||
build-atoms,
|
||||
build-docs,
|
||||
build-companion,
|
||||
typecheck-companion,
|
||||
lint-companion,
|
||||
setup-db,
|
||||
e2e,
|
||||
e2e-api-v2,
|
||||
@@ -506,5 +522,9 @@ jobs:
|
||||
) ||
|
||||
(
|
||||
needs.prepare.outputs.has-companion == 'true' &&
|
||||
needs.build-companion.result != 'success'
|
||||
(
|
||||
needs.build-companion.result != 'success' ||
|
||||
needs.typecheck-companion.result != 'success' ||
|
||||
needs.lint-companion.result != 'success'
|
||||
)
|
||||
)
|
||||
|
||||
@@ -102,6 +102,8 @@ describe("POST /api/selected-calendars", () => {
|
||||
channelResourceUri: null,
|
||||
channelExpiration: null,
|
||||
syncSubscribedAt: null,
|
||||
syncSubscribedErrorAt: null,
|
||||
syncSubscribedErrorCount: 0,
|
||||
syncToken: null,
|
||||
syncedAt: null,
|
||||
syncErrorAt: null,
|
||||
@@ -157,6 +159,8 @@ describe("POST /api/selected-calendars", () => {
|
||||
channelResourceUri: null,
|
||||
channelExpiration: null,
|
||||
syncSubscribedAt: null,
|
||||
syncSubscribedErrorAt: null,
|
||||
syncSubscribedErrorCount: 0,
|
||||
syncToken: null,
|
||||
syncedAt: null,
|
||||
syncErrorAt: null,
|
||||
|
||||
@@ -47,6 +47,8 @@ const loadConfig = (): AppConfig => {
|
||||
e2e: getEnv("IS_E2E", "false") === "true",
|
||||
enableSlotsWorkers: getEnv("ENABLE_SLOTS_WORKERS", "true") === "true",
|
||||
slotsWorkerPoolSize: Number(getEnv("SLOTS_WORKER_POOL_SIZE", "4")),
|
||||
vercel: getEnv("VERCEL", "false") === "true",
|
||||
enableAsyncTasker: getEnv("ENABLE_ASYNC_TASKER", "false") === "true",
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -35,4 +35,6 @@ export type AppConfig = {
|
||||
e2e: boolean;
|
||||
enableSlotsWorkers: boolean;
|
||||
slotsWorkerPoolSize: number;
|
||||
vercel: boolean;
|
||||
enableAsyncTasker: boolean;
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { BookingEventHandlerModule } from "@/lib/modules/booking-event-handler.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -43,6 +44,7 @@ import { Module } from "@nestjs/common";
|
||||
RegularBookingModule,
|
||||
RecurringBookingModule,
|
||||
InstantBookingModule,
|
||||
BookingEventHandlerModule,
|
||||
],
|
||||
providers: [
|
||||
TokensRepository,
|
||||
|
||||
@@ -50,6 +50,7 @@ import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { BookingEventHandlerModule } from "@/lib/modules/booking-event-handler.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -71,6 +72,7 @@ import { Module } from "@nestjs/common";
|
||||
RegularBookingModule,
|
||||
RecurringBookingModule,
|
||||
InstantBookingModule,
|
||||
BookingEventHandlerModule,
|
||||
],
|
||||
providers: [
|
||||
TokensRepository,
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { makeUserActor } from "@calcom/platform-libraries/bookings";
|
||||
import type {
|
||||
BookingInputLocation_2024_08_13,
|
||||
UpdateBookingInputLocation_2024_08_13,
|
||||
UpdateBookingLocationInput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import { Booking } from "@calcom/prisma/client";
|
||||
import { ForbiddenException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { EventTypeAccessService } from "@/modules/event-types/services/event-type-access.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Injectable, Logger, NotFoundException, ForbiddenException } from "@nestjs/common";
|
||||
|
||||
import type {
|
||||
UpdateBookingLocationInput_2024_08_13,
|
||||
BookingInputLocation_2024_08_13,
|
||||
UpdateBookingInputLocation_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import { Booking } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class BookingLocationService_2024_08_13 {
|
||||
@@ -24,7 +25,8 @@ export class BookingLocationService_2024_08_13 {
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly inputService: InputBookingsService_2024_08_13,
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly eventTypeAccessService: EventTypeAccessService
|
||||
private readonly eventTypeAccessService: EventTypeAccessService,
|
||||
private readonly bookingEventHandlerService: BookingEventHandlerService
|
||||
) {}
|
||||
|
||||
async updateBookingLocation(
|
||||
@@ -66,6 +68,7 @@ export class BookingLocationService_2024_08_13 {
|
||||
user: ApiAuthGuardUser
|
||||
) {
|
||||
const bookingUid = existingBooking.uid;
|
||||
const oldLocation = existingBooking.location;
|
||||
const bookingLocation = this.getLocationValue(inputLocation) ?? existingBooking.location;
|
||||
|
||||
if (!existingBooking.userId) {
|
||||
@@ -96,6 +99,19 @@ export class BookingLocationService_2024_08_13 {
|
||||
responses: updatedBookingResponses,
|
||||
});
|
||||
|
||||
await this.bookingEventHandlerService.onLocationChanged({
|
||||
bookingUid: existingBooking.uid,
|
||||
actor: makeUserActor(user.uuid),
|
||||
organizationId: existingBookingHost.organizationId ?? null,
|
||||
source: "API_V2",
|
||||
auditData: {
|
||||
location: {
|
||||
old: oldLocation,
|
||||
new: bookingLocation,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return this.bookingsService.getBooking(updatedBooking.uid, user);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { SchedulesController_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/controllers/schedules.controller";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { InputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/input-schedules.service";
|
||||
@@ -9,7 +10,7 @@ import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, UsersModule, TokensModule],
|
||||
imports: [PrismaModule, UsersModule, TokensModule, EventTypesModule_2024_06_14],
|
||||
providers: [
|
||||
SchedulesRepository_2024_06_11,
|
||||
SchedulesService_2024_06_11,
|
||||
|
||||
@@ -244,4 +244,31 @@ export class SchedulesRepository_2024_06_11 {
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
async getSchedulesByIds(scheduleIds: number[], skip: number, take: number) {
|
||||
return this.dbRead.prisma.schedule.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: scheduleIds,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
name: true,
|
||||
timeZone: true,
|
||||
availability: {
|
||||
select: {
|
||||
days: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
date: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { InputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/input-schedules.service";
|
||||
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import type {
|
||||
CreateScheduleInput_2024_06_11,
|
||||
ScheduleOutput_2024_06_11,
|
||||
UpdateScheduleInput_2024_06_11,
|
||||
} from "@calcom/platform-types";
|
||||
import type { Schedule } from "@calcom/prisma/client";
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { InputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/input-schedules.service";
|
||||
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService_2024_06_11 {
|
||||
@@ -17,10 +17,11 @@ export class SchedulesService_2024_06_11 {
|
||||
private readonly schedulesRepository: SchedulesRepository_2024_06_11,
|
||||
private readonly inputSchedulesService: InputSchedulesService_2024_06_11,
|
||||
private readonly outputSchedulesService: OutputSchedulesService_2024_06_11,
|
||||
private readonly usersRepository: UsersRepository
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14
|
||||
) {}
|
||||
|
||||
async createUserDefaultSchedule(userId: number, timeZone: string) {
|
||||
async createUserDefaultSchedule(userId: number, timeZone: string): Promise<ScheduleOutput_2024_06_11> {
|
||||
const defaultSchedule = {
|
||||
isDefault: true,
|
||||
name: "Default schedule",
|
||||
@@ -45,7 +46,7 @@ export class SchedulesService_2024_06_11 {
|
||||
return this.outputSchedulesService.getResponseSchedule(createdSchedule);
|
||||
}
|
||||
|
||||
async getUserScheduleDefault(userId: number) {
|
||||
async getUserScheduleDefault(userId: number): Promise<ScheduleOutput_2024_06_11 | null> {
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
|
||||
if (!user?.defaultScheduleId) return null;
|
||||
@@ -56,7 +57,7 @@ export class SchedulesService_2024_06_11 {
|
||||
return this.outputSchedulesService.getResponseSchedule(defaultSchedule);
|
||||
}
|
||||
|
||||
async getUserSchedule(userId: number, scheduleId: number) {
|
||||
async getUserSchedule(userId: number, scheduleId: number): Promise<ScheduleOutput_2024_06_11> {
|
||||
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
|
||||
|
||||
if (!existingSchedule) {
|
||||
@@ -68,16 +69,71 @@ export class SchedulesService_2024_06_11 {
|
||||
return this.outputSchedulesService.getResponseSchedule(existingSchedule);
|
||||
}
|
||||
|
||||
async getUserSchedules(userId: number) {
|
||||
const schedules = await this.schedulesRepository.getSchedulesByUserId(userId);
|
||||
return Promise.all(
|
||||
schedules.map(async (schedule) => {
|
||||
return this.outputSchedulesService.getResponseSchedule(schedule);
|
||||
})
|
||||
);
|
||||
async getUserSchedules(userId: number, eventTypeId?: number): Promise<ScheduleOutput_2024_06_11[]> {
|
||||
if (!eventTypeId) {
|
||||
const schedules = await this.schedulesRepository.getSchedulesByUserId(userId);
|
||||
return Promise.all(
|
||||
schedules.map(async (schedule) => {
|
||||
return this.outputSchedulesService.getResponseSchedule(schedule);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return this.getUserSchedulesForEventType(userId, eventTypeId);
|
||||
}
|
||||
|
||||
async updateUserSchedule(userId: number, scheduleId: number, bodySchedule: UpdateScheduleInput_2024_06_11) {
|
||||
private async getUserSchedulesForEventType(
|
||||
userId: number,
|
||||
eventTypeId: number
|
||||
): Promise<ScheduleOutput_2024_06_11[]> {
|
||||
const userEventType = await this.eventTypesRepository.getUserEventType(userId, eventTypeId);
|
||||
|
||||
let effectiveScheduleId: number | null = null;
|
||||
|
||||
if (userEventType) {
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
effectiveScheduleId = userEventType.scheduleId ?? user?.defaultScheduleId ?? null;
|
||||
} else {
|
||||
// if its not a user owned event type, check if it's a team event type where user one of the host
|
||||
const eventType = await this.eventTypesRepository.getEventTypeByIdWithHosts(eventTypeId);
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
const userHost = eventType.hosts?.find((host) => host.userId === userId);
|
||||
|
||||
if (!userHost) {
|
||||
throw new NotFoundException(`User ${userId} is not associated with event type ${eventTypeId}`);
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
|
||||
effectiveScheduleId = eventType.scheduleId ?? userHost.scheduleId ?? user?.defaultScheduleId ?? null;
|
||||
}
|
||||
|
||||
if (!effectiveScheduleId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const schedule = await this.schedulesRepository.getScheduleById(effectiveScheduleId);
|
||||
|
||||
if (!schedule) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (schedule.userId !== userId) {
|
||||
throw new ForbiddenException(`User ${userId} does not have access to schedule ${effectiveScheduleId}`);
|
||||
}
|
||||
|
||||
return [await this.outputSchedulesService.getResponseSchedule(schedule)];
|
||||
}
|
||||
|
||||
async updateUserSchedule(
|
||||
userId: number,
|
||||
scheduleId: number,
|
||||
bodySchedule: UpdateScheduleInput_2024_06_11
|
||||
): Promise<ScheduleOutput_2024_06_11> {
|
||||
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
|
||||
|
||||
if (!existingSchedule) {
|
||||
@@ -106,7 +162,7 @@ export class SchedulesService_2024_06_11 {
|
||||
return this.outputSchedulesService.getResponseSchedule(updatedSchedule);
|
||||
}
|
||||
|
||||
async deleteUserSchedule(userId: number, scheduleId: number) {
|
||||
async deleteUserSchedule(userId: number, scheduleId: number): Promise<Schedule> {
|
||||
const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId);
|
||||
|
||||
if (!existingSchedule) {
|
||||
@@ -118,7 +174,7 @@ export class SchedulesService_2024_06_11 {
|
||||
return this.schedulesRepository.deleteScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
checkUserOwnsSchedule(userId: number, schedule: Pick<Schedule, "id" | "userId">) {
|
||||
checkUserOwnsSchedule(userId: number, schedule: Pick<Schedule, "id" | "userId">): void {
|
||||
if (userId !== schedule.userId) {
|
||||
throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ export type Environment = {
|
||||
ENABLE_SLOTS_WORKERS: string;
|
||||
SLOTS_WORKER_POOL_SIZE: string;
|
||||
USE_POOL: string;
|
||||
VERCEL: string;
|
||||
ENABLE_ASYNC_TASKER: string;
|
||||
};
|
||||
|
||||
export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environment[K]): Environment[K] => {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
import { BookingAuditProducerService } from "@/lib/services/booking-audit-producer.service";
|
||||
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
|
||||
import { HashedLinkService } from "@/lib/services/hashed-link.service";
|
||||
import { TaskerService } from "@/lib/services/tasker.service";
|
||||
import { Module, Scope } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: Logger,
|
||||
useFactory: () => {
|
||||
return new Logger();
|
||||
},
|
||||
scope: Scope.TRANSIENT,
|
||||
},
|
||||
TaskerService,
|
||||
HashedLinkService,
|
||||
BookingAuditProducerService,
|
||||
BookingEventHandlerService,
|
||||
],
|
||||
exports: [BookingEventHandlerService],
|
||||
})
|
||||
export class BookingEventHandlerModule {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Module, Scope } from "@nestjs/common";
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
import { CalendarsSyncTaskerService } from "@/lib/services/tasker/calendars-sync-tasker.service";
|
||||
import { CalendarsTaskService } from "@/lib/services/tasker/calendars-task.service";
|
||||
import { CalendarsTasker } from "@/lib/services/tasker/calendars-tasker.service";
|
||||
import { CalendarsTriggerTaskerService } from "@/lib/services/tasker/calendars-trigger-tasker.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [
|
||||
{
|
||||
provide: Logger,
|
||||
useFactory: () => {
|
||||
return new Logger();
|
||||
},
|
||||
scope: Scope.TRANSIENT,
|
||||
},
|
||||
CalendarsTaskService,
|
||||
CalendarsSyncTaskerService,
|
||||
CalendarsTriggerTaskerService,
|
||||
CalendarsTasker,
|
||||
],
|
||||
exports: [CalendarsTasker],
|
||||
})
|
||||
export class CalendarsTaskerModule {}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Module, Scope } from "@nestjs/common";
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
import { PrismaPlatformBillingRepository } from "@/lib/repositories/prisma-platform-billing.repository";
|
||||
import { StripeBillingProviderService } from "@/lib/services/stripe-billing-provider.service";
|
||||
import { PlatformBillingSyncTaskerService } from "@/lib/services/tasker/platform-billing-sync-tasker.service";
|
||||
import { PlatformBillingTaskService } from "@/lib/services/tasker/platform-billing-task.service";
|
||||
import { PlatformBillingTasker } from "@/lib/services/tasker/platform-billing-tasker.service";
|
||||
import { PlatformBillingTriggerTaskerService } from "@/lib/services/tasker/platform-billing-trigger-tasker.service";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StripeModule, OrganizationsModule],
|
||||
providers: [
|
||||
PrismaPlatformBillingRepository,
|
||||
StripeBillingProviderService,
|
||||
{
|
||||
provide: Logger,
|
||||
useFactory: () => {
|
||||
return new Logger();
|
||||
},
|
||||
scope: Scope.TRANSIENT,
|
||||
},
|
||||
PlatformBillingTaskService,
|
||||
PlatformBillingSyncTaskerService,
|
||||
PlatformBillingTriggerTaskerService,
|
||||
PlatformBillingTasker,
|
||||
],
|
||||
exports: [PlatformBillingTasker],
|
||||
})
|
||||
export class PlatformBillingTaskerModule {}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { PlatformBillingRepository } from "@calcom/platform-libraries/organizations";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaPlatformBillingRepository extends PlatformBillingRepository {
|
||||
constructor(dbWrite: PrismaWriteService) {
|
||||
super(dbWrite.prisma as unknown as PrismaClient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { StripeBillingService } from "@calcom/platform-libraries";
|
||||
import type { IBillingProviderService } from "@calcom/platform-libraries/organizations";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
|
||||
@Injectable()
|
||||
export class StripeBillingProviderService
|
||||
extends StripeBillingService
|
||||
implements Pick<IBillingProviderService, "createSubscriptionUsageRecord">
|
||||
{
|
||||
constructor(readonly stripeService: StripeService) {
|
||||
super(stripeService.getStripe());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
import { CalendarsTaskService } from "@/lib/services/tasker/calendars-task.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { CalendarsSyncTasker as BaseCalendarsSyncTasker } from "@calcom/platform-libraries/calendars";
|
||||
|
||||
@Injectable()
|
||||
export class CalendarsSyncTaskerService extends BaseCalendarsSyncTasker {
|
||||
constructor(calendarsTaskService: CalendarsTaskService, logger: Logger) {
|
||||
super({
|
||||
logger,
|
||||
calendarsTaskService,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
|
||||
import { CalendarsTaskService as BaseCalendarsTaskService } from "@calcom/platform-libraries/calendars";
|
||||
|
||||
@Injectable()
|
||||
export class CalendarsTaskService extends BaseCalendarsTaskService {
|
||||
constructor(prismaReadService: PrismaReadService, logger: Logger) {
|
||||
super({
|
||||
logger,
|
||||
prisma: prismaReadService.prisma,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
import { CalendarsSyncTaskerService } from "@/lib/services/tasker/calendars-sync-tasker.service";
|
||||
import { CalendarsTriggerTaskerService } from "@/lib/services/tasker/calendars-trigger-tasker.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { CalendarsTasker as BaseCalendarsTasker } from "@calcom/platform-libraries/calendars";
|
||||
|
||||
@Injectable()
|
||||
export class CalendarsTasker extends BaseCalendarsTasker {
|
||||
constructor(
|
||||
syncTasker: CalendarsSyncTaskerService,
|
||||
asyncTasker: CalendarsTriggerTaskerService,
|
||||
logger: Logger
|
||||
) {
|
||||
super({
|
||||
logger,
|
||||
asyncTasker: asyncTasker,
|
||||
syncTasker: syncTasker,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { CalendarsTriggerTasker as BaseCalendarsTriggerTasker } from "@calcom/platform-libraries/calendars";
|
||||
|
||||
@Injectable()
|
||||
export class CalendarsTriggerTaskerService extends BaseCalendarsTriggerTasker {
|
||||
constructor(logger: Logger) {
|
||||
super({
|
||||
logger,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
import { PlatformBillingTaskService } from "@/lib/services/tasker/platform-billing-task.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { PlatformOrganizationBillingSyncTasker as BasePlatformOrganizationBillingSyncTasker } from "@calcom/platform-libraries/organizations";
|
||||
|
||||
@Injectable()
|
||||
export class PlatformBillingSyncTaskerService extends BasePlatformOrganizationBillingSyncTasker {
|
||||
constructor(billingTaskService: PlatformBillingTaskService, logger: Logger) {
|
||||
super({
|
||||
logger,
|
||||
billingTaskService,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
import { PrismaPlatformBillingRepository } from "@/lib/repositories/prisma-platform-billing.repository";
|
||||
import { StripeBillingProviderService } from "@/lib/services/stripe-billing-provider.service";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { PlatformOrganizationBillingTaskService as BasePlatformOrganizationBillingTaskService } from "@calcom/platform-libraries/organizations";
|
||||
|
||||
@Injectable()
|
||||
export class PlatformBillingTaskService extends BasePlatformOrganizationBillingTaskService {
|
||||
constructor(
|
||||
organizationRepository: OrganizationsRepository,
|
||||
platformBillingRepository: PrismaPlatformBillingRepository,
|
||||
billingProviderService: StripeBillingProviderService,
|
||||
logger: Logger
|
||||
) {
|
||||
super({
|
||||
logger,
|
||||
organizationRepository,
|
||||
platformBillingRepository,
|
||||
billingProviderService,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { PlatformOrganizationBillingTasker as BasePlatformOrganizationBillingTasker } from "@calcom/platform-libraries/organizations";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
import { PlatformBillingSyncTaskerService } from "@/lib/services/tasker/platform-billing-sync-tasker.service";
|
||||
import { PlatformBillingTriggerTaskerService } from "@/lib/services/tasker/platform-billing-trigger-tasker.service";
|
||||
|
||||
@Injectable()
|
||||
export class PlatformBillingTasker extends BasePlatformOrganizationBillingTasker {
|
||||
constructor(
|
||||
syncTasker: PlatformBillingSyncTaskerService,
|
||||
asyncTasker: PlatformBillingTriggerTaskerService,
|
||||
logger: Logger
|
||||
) {
|
||||
super({
|
||||
logger,
|
||||
asyncTasker: asyncTasker,
|
||||
syncTasker: syncTasker,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Logger } from "@/lib/logger.bridge";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { PlatformOrganizationBillingTriggerTasker as BasePlatformOrganizationBillingTriggerTasker } from "@calcom/platform-libraries/organizations";
|
||||
|
||||
@Injectable()
|
||||
export class PlatformBillingTriggerTaskerService extends BasePlatformOrganizationBillingTriggerTasker {
|
||||
constructor(logger: Logger) {
|
||||
super({
|
||||
logger,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { BullModule } from "@nestjs/bull";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { PlatformBillingTaskerModule } from "@/lib/modules/platform-billing-tasker.module";
|
||||
import { BillingProcessor } from "@/modules/billing/billing.processor";
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { BillingController } from "@/modules/billing/controllers/billing.controller";
|
||||
import { IsUserInBillingOrg } from "@/modules/billing/guards/is-user-in-billing-org";
|
||||
import { BillingServiceCachingProxy } from "@/modules/billing/services/billing-service-caching-proxy";
|
||||
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { BillingServiceCachingProxy } from "@/modules/billing/services/billing-service-caching-proxy";
|
||||
import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
@@ -14,8 +17,6 @@ import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { BullModule } from "@nestjs/bull";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -32,6 +33,7 @@ import { Module } from "@nestjs/common";
|
||||
},
|
||||
}),
|
||||
UsersModule,
|
||||
PlatformBillingTaskerModule,
|
||||
],
|
||||
providers: [
|
||||
BillingConfigService,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { PlatformBilling, Team } from "@calcom/prisma/client";
|
||||
export type BillingData = {
|
||||
team: (Team & { platformBilling: PlatformBilling | null }) | null;
|
||||
status: "valid" | "no_subscription" | "no_billing";
|
||||
plan: string;
|
||||
plan: PlatformPlan | "none";
|
||||
};
|
||||
|
||||
export interface IBillingService {
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
import { AppConfig } from "@/config/type";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
|
||||
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { InjectQueue } from "@nestjs/bull";
|
||||
import {
|
||||
BadRequestException,
|
||||
@@ -22,6 +11,16 @@ import { ConfigService } from "@nestjs/config";
|
||||
import { Queue } from "bull";
|
||||
import { DateTime } from "luxon";
|
||||
import Stripe from "stripe";
|
||||
import { AppConfig } from "@/config/type";
|
||||
import { PlatformBillingTasker } from "@/lib/services/tasker/platform-billing-tasker.service";
|
||||
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { BillingData, IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
|
||||
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
|
||||
@Injectable()
|
||||
export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
@@ -35,14 +34,13 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
private readonly configService: ConfigService<AppConfig>,
|
||||
private readonly billingConfigService: BillingConfigService,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||
private readonly platformBillingTasker: PlatformBillingTasker,
|
||||
@InjectQueue(BILLING_QUEUE) private readonly billingQueue: Queue
|
||||
) {
|
||||
this.webAppUrl = this.configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
|
||||
}
|
||||
|
||||
async getBillingData(teamId: number) {
|
||||
async getBillingData(teamId: number): Promise<BillingData> {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
|
||||
if (teamWithBilling?.platformBilling) {
|
||||
@@ -52,7 +50,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
return {
|
||||
team: teamWithBilling,
|
||||
status: "valid" as const,
|
||||
plan: teamWithBilling.platformBilling.plan,
|
||||
plan: teamWithBilling.platformBilling.plan as PlatformPlan,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
@@ -62,7 +60,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
|
||||
async createTeamBilling(teamId: number): Promise<string> {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
let customerId = teamWithBilling?.platformBilling?.customerId;
|
||||
let customerId = teamWithBilling?.platformBilling?.customerId ?? "";
|
||||
|
||||
if (!teamWithBilling?.platformBilling) {
|
||||
customerId = await this.teamsRepository.createNewBillingRelation(teamId);
|
||||
@@ -73,10 +71,14 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
return customerId!;
|
||||
return customerId;
|
||||
}
|
||||
|
||||
async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) {
|
||||
async redirectToSubscribeCheckout(
|
||||
teamId: number,
|
||||
plan: PlatformPlan,
|
||||
customerId?: string
|
||||
): Promise<string> {
|
||||
const { url } = await this.stripeService.getStripe().checkout.sessions.create({
|
||||
customer: customerId,
|
||||
line_items: [
|
||||
@@ -109,7 +111,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
return url;
|
||||
}
|
||||
|
||||
async updateSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
|
||||
async updateSubscriptionForTeam(teamId: number, plan: PlatformPlan): Promise<string> {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
const customerId = teamWithBilling?.platformBilling?.customerId;
|
||||
|
||||
@@ -134,7 +136,11 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
return url;
|
||||
}
|
||||
|
||||
async setPerBookingSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) {
|
||||
async setPerBookingSubscriptionForTeam(
|
||||
teamId: number,
|
||||
subscriptionId: string,
|
||||
plan: PlatformPlan
|
||||
): Promise<ReturnType<typeof BillingRepository.prototype.updateTeamBilling>> {
|
||||
const billingCycleStart = DateTime.now().get("day");
|
||||
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
|
||||
|
||||
@@ -152,7 +158,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
subscriptionId: string,
|
||||
plan: PlatformPlan,
|
||||
priceId: string
|
||||
) {
|
||||
): Promise<ReturnType<typeof BillingRepository.prototype.updateTeamBilling>> {
|
||||
const billingCycleStart = DateTime.now().get("day");
|
||||
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
|
||||
|
||||
@@ -166,12 +172,12 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
async handleStripeSubscriptionDeleted(event: Stripe.Event) {
|
||||
async handleStripeSubscriptionDeleted(event: Stripe.Event): Promise<void> {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
const teamId = subscription?.metadata?.teamId;
|
||||
const plan = PlatformPlan[subscription?.metadata?.plan?.toUpperCase() as keyof typeof PlatformPlan];
|
||||
if (teamId && plan) {
|
||||
const currentBilling = await this.billingRepository.getBillingForTeam(Number.parseInt(teamId));
|
||||
const currentBilling = await this.billingRepository.getBillingForTeam(Number.parseInt(teamId, 10));
|
||||
if (currentBilling?.subscriptionId === subscription.id) {
|
||||
await this.billingRepository.deleteBilling(currentBilling.id);
|
||||
this.logger.log(`Stripe Subscription deleted`, {
|
||||
@@ -207,7 +213,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async handleStripePaymentSuccess(event: Stripe.Event) {
|
||||
async handleStripePaymentSuccess(event: Stripe.Event): Promise<void> {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
|
||||
const customerId = this.getCustomerIdFromInvoice(invoice);
|
||||
@@ -216,7 +222,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async handleStripePaymentFailed(event: Stripe.Event) {
|
||||
async handleStripePaymentFailed(event: Stripe.Event): Promise<void> {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
|
||||
const customerId = this.getCustomerIdFromInvoice(invoice);
|
||||
@@ -225,7 +231,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async handleStripePaymentPastDue(event: Stripe.Event) {
|
||||
async handleStripePaymentPastDue(event: Stripe.Event): Promise<void> {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
|
||||
const customerId = this.getCustomerIdFromInvoice(invoice);
|
||||
@@ -249,14 +255,14 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async handleStripeCheckoutEvents(event: Stripe.Event) {
|
||||
async handleStripeCheckoutEvents(event: Stripe.Event): Promise<void> {
|
||||
const checkoutSession = event.data.object as Stripe.Checkout.Session;
|
||||
|
||||
if (!checkoutSession.metadata?.teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const teamId = Number.parseInt(checkoutSession.metadata.teamId);
|
||||
const teamId = Number.parseInt(checkoutSession.metadata.teamId, 10);
|
||||
const plan = checkoutSession.metadata.plan;
|
||||
if (!plan || !teamId) {
|
||||
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
|
||||
@@ -288,7 +294,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
async handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event) {
|
||||
async handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event): Promise<void> {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
|
||||
|
||||
@@ -299,12 +305,16 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
const teamWithBilling = await this.billingRepository.getBillingForTeamBySubscriptionId(subscriptionId);
|
||||
|
||||
if (teamWithBilling?.plan === "PER_ACTIVE_USER") {
|
||||
const activeManagedUsersCount = await this.getActiveManagedUsersCount(
|
||||
let activeManagedUsersCount = await this.getActiveManagedUsersCount(
|
||||
subscriptionId,
|
||||
new Date(invoice.period_start * 1000),
|
||||
new Date(invoice.period_end * 1000)
|
||||
);
|
||||
|
||||
if (activeManagedUsersCount < 0) {
|
||||
activeManagedUsersCount = 1;
|
||||
}
|
||||
|
||||
const existingSubscription = await this.stripeService
|
||||
.getStripe()
|
||||
.subscriptions.retrieve(subscriptionId);
|
||||
@@ -321,17 +331,18 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
}
|
||||
|
||||
await this.stripeService.getStripe().subscriptions.update(subscriptionId, {
|
||||
items: [
|
||||
{ id: subscriptionItem.id, quantity: activeManagedUsersCount > 0 ? activeManagedUsersCount : 1 },
|
||||
],
|
||||
items: [{ id: subscriptionItem.id, quantity: activeManagedUsersCount }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getActiveManagedUsersCount(subscriptionId: string, invoiceStart: Date, invoiceEnd: Date) {
|
||||
const managedUsersEmails = await this.usersRepository.getOrgsManagedUserEmailsBySubscriptionId(
|
||||
subscriptionId
|
||||
);
|
||||
async getActiveManagedUsersCount(
|
||||
subscriptionId: string,
|
||||
invoiceStart: Date,
|
||||
invoiceEnd: Date
|
||||
): Promise<number> {
|
||||
const managedUsersEmails =
|
||||
await this.usersRepository.getOrgsManagedUserEmailsBySubscriptionId(subscriptionId);
|
||||
|
||||
if (!managedUsersEmails) return 0;
|
||||
|
||||
@@ -362,7 +373,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
return activeManagedUserEmailsAsAttendee.length + activeManagedUserEmailsAsHost.length;
|
||||
}
|
||||
|
||||
async updateStripeSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
|
||||
async updateStripeSubscriptionForTeam(teamId: number, plan: PlatformPlan): Promise<void> {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
|
||||
if (!teamWithBilling?.platformBilling || !teamWithBilling?.platformBilling.subscriptionId) {
|
||||
@@ -424,13 +435,37 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
startTime: Date;
|
||||
fromReschedule?: string | null;
|
||||
}
|
||||
) {
|
||||
): Promise<boolean | undefined> {
|
||||
if (this.configService.get("e2e")) {
|
||||
return true;
|
||||
}
|
||||
const { uid, startTime, fromReschedule } = booking;
|
||||
|
||||
const delay = startTime.getTime() - Date.now();
|
||||
if (this.configService.get("enableAsyncTasker")) {
|
||||
if (fromReschedule) {
|
||||
this.platformBillingTasker.rescheduleUsageIncrement({
|
||||
payload: { bookingUid: uid },
|
||||
options: { delay: startTime },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
this.platformBillingTasker.incrementUsage({
|
||||
payload: { userId },
|
||||
options: {
|
||||
delay: startTime,
|
||||
tags: [`platform.billing.usage.${uid}`],
|
||||
idempotencyKey: `platform.billing.usage.${uid}-${userId}`,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
let delay = startTime.getTime() - Date.now();
|
||||
|
||||
if (delay < 0) {
|
||||
delay = 0;
|
||||
}
|
||||
|
||||
if (fromReschedule) {
|
||||
// cancel the usage increment job for the booking that is being rescheduled
|
||||
await this.cancelUsageByBookingUid(fromReschedule);
|
||||
@@ -441,7 +476,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
{
|
||||
userId,
|
||||
} satisfies IncrementJobDataType,
|
||||
{ delay: delay > 0 ? delay : 0, jobId: `increment-${uid}`, removeOnComplete: true }
|
||||
{ delay: delay, jobId: `increment-${uid}`, removeOnComplete: true }
|
||||
);
|
||||
this.logger.log(`Added stripe usage increment job for booking ${uid} and user ${userId}`);
|
||||
}
|
||||
@@ -451,10 +486,16 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
* Cancels the usage increment job for a booking when it is cancelled.
|
||||
* Removing an attendee from a booking does not cancel the usage increment job.
|
||||
*/
|
||||
async cancelUsageByBookingUid(bookingUid: string) {
|
||||
async cancelUsageByBookingUid(bookingUid: string): Promise<boolean | undefined> {
|
||||
if (this.configService.get("e2e")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.configService.get("enableAsyncTasker")) {
|
||||
await this.platformBillingTasker.cancelUsageIncrement({ payload: { bookingUid } });
|
||||
return true;
|
||||
}
|
||||
|
||||
const job = await this.billingQueue.getJob(`increment-${bookingUid}`);
|
||||
if (job) {
|
||||
await job.remove();
|
||||
@@ -462,7 +503,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async cancelTeamSubscription(teamId: number) {
|
||||
async cancelTeamSubscription(teamId: number): Promise<void> {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
const customerId = teamWithBilling?.platformBilling?.customerId;
|
||||
|
||||
@@ -484,7 +525,7 @@ export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
try {
|
||||
await this.billingQueue.close();
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Logger, Module } from "@nestjs/common";
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { OrganizationsEventTypesPrivateLinksController } from "@/ee/event-types-private-links/controllers/organizations-event-types-private-links.controller";
|
||||
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||
import { InputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/input-schedules.service";
|
||||
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
|
||||
import { OrganizationMembershipService } from "@/lib/services/organization-membership.service";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
@@ -13,7 +15,6 @@ import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.ser
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { EmailModule } from "@/modules/email/email.module";
|
||||
import { EmailService } from "@/modules/email/email.service";
|
||||
import { OrganizationMembershipService } from "@/lib/services/organization-membership.service";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository";
|
||||
@@ -37,8 +38,8 @@ import { OrganizationsRepository } from "@/modules/organizations/index/organizat
|
||||
import { OrganizationsService } from "@/modules/organizations/index/organizations.service";
|
||||
import { OrganizationsMembershipsController } from "@/modules/organizations/memberships/organizations-membership.controller";
|
||||
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
|
||||
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
|
||||
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
|
||||
import { OrganizationsOrganizationsModule } from "@/modules/organizations/organizations/organizations-organizations.module";
|
||||
import { OrganizationsRolesModule } from "@/modules/organizations/roles/organizations-roles.module";
|
||||
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
|
||||
@@ -84,7 +85,6 @@ import { TeamRoutingFormWorkflowsService } from "@/modules/workflows/services/te
|
||||
import { WorkflowsInputService } from "@/modules/workflows/services/workflows.input.service";
|
||||
import { WorkflowsOutputService } from "@/modules/workflows/services/workflows.output.service";
|
||||
import { WorkflowsRepository } from "@/modules/workflows/workflows.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -162,6 +162,7 @@ import { Module } from "@nestjs/common";
|
||||
TeamsMembershipsService,
|
||||
TeamsMembershipsRepository,
|
||||
OAuthClientRepository,
|
||||
Logger,
|
||||
],
|
||||
exports: [
|
||||
OrganizationsService,
|
||||
@@ -184,6 +185,7 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsEventTypesService,
|
||||
OrganizationsConferencingService,
|
||||
OrganizationsStripeService,
|
||||
OrganizationMembershipService,
|
||||
],
|
||||
controllers: [
|
||||
OrganizationsTeamsController,
|
||||
@@ -202,4 +204,4 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsEventTypesPrivateLinksController,
|
||||
],
|
||||
})
|
||||
export class OrganizationsModule { }
|
||||
export class OrganizationsModule {}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsNumber, IsOptional } from "class-validator";
|
||||
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
export class GetTeamSchedulesQuery extends SkipTakePagination {
|
||||
@ApiPropertyOptional({
|
||||
description: "Filter schedules by event type ID",
|
||||
example: 1,
|
||||
})
|
||||
@Transform(({ value }: { value: string }) => value && parseInt(value))
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
eventTypeId?: number;
|
||||
}
|
||||
|
||||
export class GetUserSchedulesQuery {
|
||||
@ApiPropertyOptional({
|
||||
description: "Filter schedules by event type ID",
|
||||
example: 1,
|
||||
})
|
||||
@Transform(({ value }: { value: string }) => value && parseInt(value))
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
eventTypeId?: number;
|
||||
}
|
||||
+14
-8
@@ -14,12 +14,16 @@ 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 { IsUserInOrgTeam } from "@/modules/auth/guards/users/is-user-in-org-team.guard";
|
||||
import {
|
||||
GetTeamSchedulesQuery,
|
||||
GetUserSchedulesQuery,
|
||||
} from "@/modules/organizations/teams/schedules/inputs/teams-schedules.input";
|
||||
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
|
||||
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { GetSchedulesOutput_2024_06_11, SkipTakePagination } from "@calcom/platform-types";
|
||||
import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/teams/:teamId",
|
||||
@@ -43,14 +47,14 @@ export class OrganizationsTeamsSchedulesController {
|
||||
@DocsTags("Orgs / Teams / Schedules")
|
||||
@ApiOperation({ summary: "Get all team member schedules" })
|
||||
async getTeamSchedules(
|
||||
// note(Lauirs): putting orgId so swagger is generacted correctly
|
||||
// note(Lauris): putting orgId so swagger is generated correctly
|
||||
@Param("orgId", ParseIntPipe) _orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
@Query() queryParams: GetTeamSchedulesQuery
|
||||
): Promise<GetSchedulesOutput_2024_06_11> {
|
||||
const { skip, take } = queryParams;
|
||||
const { skip, take, eventTypeId } = queryParams;
|
||||
|
||||
const schedules = await this.teamsSchedulesService.getTeamSchedules(teamId, skip, take);
|
||||
const schedules = await this.teamsSchedulesService.getTeamSchedules(teamId, skip, take, eventTypeId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
@@ -67,12 +71,14 @@ export class OrganizationsTeamsSchedulesController {
|
||||
summary: "Get schedules of a team member",
|
||||
})
|
||||
async getUserSchedules(
|
||||
// note(Lauirs): putting orgId and teamId so swagger is generacted correctly
|
||||
// note(Lauris): putting orgId and teamId so swagger is generated correctly
|
||||
@Param("orgId", ParseIntPipe) _orgId: number,
|
||||
@Param("teamId", ParseIntPipe) _teamId: number,
|
||||
@Param("userId", ParseIntPipe) userId: number
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@Query() queryParams: GetUserSchedulesQuery
|
||||
): Promise<GetSchedulesOutput_2024_06_11> {
|
||||
const schedules = await this.schedulesService.getUserSchedules(userId);
|
||||
const { eventTypeId } = queryParams;
|
||||
const schedules = await this.schedulesService.getUserSchedules(userId, eventTypeId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
|
||||
+247
-2
@@ -4,11 +4,13 @@ import type {
|
||||
GetSchedulesOutput_2024_06_11,
|
||||
ScheduleOutput_2024_06_11,
|
||||
} from "@calcom/platform-types";
|
||||
import type { Schedule, Team, User } from "@calcom/prisma/client";
|
||||
import type { EventType, Schedule, Team, User } from "@calcom/prisma/client";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import request from "supertest";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
@@ -19,6 +21,7 @@ import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { bootstrap } from "@/bootstrap";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
@@ -32,6 +35,9 @@ describe("Organizations Teams Schedules Endpoints", () => {
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
let scheduleRepositoryFixture: SchedulesRepositoryFixture;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let hostsRepositoryFixture: HostsRepositoryFixture;
|
||||
let prismaWriteService: PrismaWriteService;
|
||||
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
@@ -41,6 +47,11 @@ describe("Organizations Teams Schedules Endpoints", () => {
|
||||
|
||||
let userSchedule: Schedule;
|
||||
let user2Schedule: Schedule;
|
||||
let userEventTypeSchedule: Schedule;
|
||||
let teamEventTypeSchedule: Schedule;
|
||||
|
||||
let userEventType: EventType;
|
||||
let teamEventType: EventType;
|
||||
|
||||
const userEmail = `organizations-teams-schedules-admin-${randomString()}@api.com`;
|
||||
const userEmail2 = `organizations-teams-schedules-member-${randomString()}@api.com`;
|
||||
@@ -66,6 +77,9 @@ describe("Organizations Teams Schedules Endpoints", () => {
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
scheduleRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
|
||||
prismaWriteService = moduleRef.get(PrismaWriteService);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
@@ -97,6 +111,26 @@ describe("Organizations Teams Schedules Endpoints", () => {
|
||||
timeZone: "America/New_York",
|
||||
});
|
||||
|
||||
userEventTypeSchedule = await scheduleRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
id: user2.id,
|
||||
},
|
||||
},
|
||||
name: `organizations-teams-schedules-user-event-type-schedule-${randomString()}`,
|
||||
timeZone: "America/New_York",
|
||||
});
|
||||
|
||||
teamEventTypeSchedule = await scheduleRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
id: user2.id,
|
||||
},
|
||||
},
|
||||
name: `organizations-teams-schedules-team-event-type-schedule-${randomString()}`,
|
||||
timeZone: "America/New_York",
|
||||
});
|
||||
|
||||
nonOrgUser = await userRepositoryFixture.create({
|
||||
email: nonOrgUserEmail,
|
||||
username: nonOrgUserEmail,
|
||||
@@ -204,6 +238,50 @@ describe("Organizations Teams Schedules Endpoints", () => {
|
||||
},
|
||||
});
|
||||
|
||||
userEventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `User Event Type ${randomString()}`,
|
||||
slug: `user-event-type-${randomString()}`,
|
||||
length: 30,
|
||||
schedule: {
|
||||
connect: {
|
||||
id: userEventTypeSchedule.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
user2.id
|
||||
);
|
||||
|
||||
teamEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
title: `Team Event Type ${randomString()}`,
|
||||
slug: `team-event-type-${randomString()}`,
|
||||
length: 30,
|
||||
team: {
|
||||
connect: {
|
||||
id: orgTeam.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await hostsRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
id: user2.id,
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
connect: {
|
||||
id: teamEventType.id,
|
||||
},
|
||||
},
|
||||
schedule: {
|
||||
connect: {
|
||||
id: teamEventTypeSchedule.id,
|
||||
},
|
||||
},
|
||||
isFixed: false,
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
@@ -236,7 +314,8 @@ describe("Organizations Teams Schedules Endpoints", () => {
|
||||
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(Array.isArray(responseBody.data)).toBe(true);
|
||||
expect(responseBody.data.length).toEqual(2);
|
||||
|
||||
expect(responseBody.data.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const userOneSchedule = responseBody.data.find((schedule) => schedule.id === userSchedule.id);
|
||||
const userTwoSchedule = responseBody.data.find((schedule) => schedule.id === user2Schedule.id);
|
||||
@@ -254,7 +333,173 @@ describe("Organizations Teams Schedules Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("eventTypeId query parameter", () => {
|
||||
describe("GET /users/:userId/schedules", () => {
|
||||
it("should filter user schedules by user-owned event type", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/users/${user2.id}/schedules?eventTypeId=${userEventType.id}`
|
||||
)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<ScheduleOutput_2024_06_11[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(Array.isArray(responseBody.data)).toBe(true);
|
||||
expect(responseBody.data.length).toEqual(1);
|
||||
expect(responseBody.data[0].id).toEqual(userEventTypeSchedule.id);
|
||||
expect(responseBody.data[0].name).toEqual(userEventTypeSchedule.name);
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter user schedules by team event type where user is a host", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/users/${user2.id}/schedules?eventTypeId=${teamEventType.id}`
|
||||
)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<ScheduleOutput_2024_06_11[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(Array.isArray(responseBody.data)).toBe(true);
|
||||
expect(responseBody.data.length).toEqual(1);
|
||||
expect(responseBody.data[0].id).toEqual(teamEventTypeSchedule.id);
|
||||
expect(responseBody.data[0].name).toEqual(teamEventTypeSchedule.name);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 404 when event type does not exist", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/users/${user2.id}/schedules?eventTypeId=999999`
|
||||
)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should return 404 when user is not associated with event type", async () => {
|
||||
const otherUserEventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `Other User Event Type ${randomString()}`,
|
||||
slug: `other-user-event-type-${randomString()}`,
|
||||
length: 30,
|
||||
},
|
||||
user.id
|
||||
);
|
||||
|
||||
try {
|
||||
await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/users/${user2.id}/schedules?eventTypeId=${otherUserEventType.id}`
|
||||
)
|
||||
.expect(404);
|
||||
} finally {
|
||||
await eventTypesRepositoryFixture.delete(otherUserEventType.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return empty array when event type has no schedule and user has no default schedule", async () => {
|
||||
const eventTypeWithoutSchedule = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `Event Type Without Schedule ${randomString()}`,
|
||||
slug: `event-type-no-schedule-${randomString()}`,
|
||||
length: 30,
|
||||
},
|
||||
user2.id
|
||||
);
|
||||
|
||||
const user2Original = await prismaWriteService.prisma.user.findUnique({
|
||||
where: { id: user2.id },
|
||||
select: { defaultScheduleId: true },
|
||||
});
|
||||
const originalDefaultScheduleId = user2Original?.defaultScheduleId ?? null;
|
||||
|
||||
await prismaWriteService.prisma.user.update({
|
||||
where: { id: user2.id },
|
||||
data: { defaultScheduleId: null },
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/users/${user2.id}/schedules?eventTypeId=${eventTypeWithoutSchedule.id}`
|
||||
)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<ScheduleOutput_2024_06_11[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(Array.isArray(responseBody.data)).toBe(true);
|
||||
expect(responseBody.data.length).toEqual(0);
|
||||
} finally {
|
||||
await prismaWriteService.prisma.user.update({
|
||||
where: { id: user2.id },
|
||||
data: { defaultScheduleId: originalDefaultScheduleId },
|
||||
});
|
||||
await eventTypesRepositoryFixture.delete(eventTypeWithoutSchedule.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /schedules (team schedules)", () => {
|
||||
it("should filter team schedules by event type", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/schedules?eventTypeId=${teamEventType.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(Array.isArray(responseBody.data)).toBe(true);
|
||||
|
||||
expect(responseBody.data.length).toBeGreaterThanOrEqual(1);
|
||||
const user2ScheduleFound = responseBody.data.find(
|
||||
(schedule) => schedule.id === teamEventTypeSchedule.id
|
||||
);
|
||||
expect(user2ScheduleFound).toBeDefined();
|
||||
expect(user2ScheduleFound?.id).toEqual(teamEventTypeSchedule.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 404 when team event type does not exist", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/schedules?eventTypeId=999999`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should return empty array when event type has no hosts with schedules", async () => {
|
||||
const teamEventTypeNoHosts = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
title: `Team Event Type No Hosts ${randomString()}`,
|
||||
slug: `team-event-type-no-hosts-${randomString()}`,
|
||||
length: 30,
|
||||
team: {
|
||||
connect: {
|
||||
id: orgTeam.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/schedules?eventTypeId=${teamEventTypeNoHosts.id}`
|
||||
)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(Array.isArray(responseBody.data)).toBe(true);
|
||||
expect(responseBody.data.length).toEqual(0);
|
||||
} finally {
|
||||
await eventTypesRepositoryFixture.delete(teamEventTypeNoHosts.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (userEventType) {
|
||||
await eventTypesRepositoryFixture.delete(userEventType.id);
|
||||
}
|
||||
if (teamEventType) {
|
||||
await eventTypesRepositoryFixture.delete(teamEventType.id);
|
||||
}
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email);
|
||||
await userRepositoryFixture.deleteByEmail(nonOrgUser.email);
|
||||
|
||||
@@ -157,4 +157,28 @@ export class TeamsEventTypesRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByIdIncludeHostsAndUserDefaultSchedule(eventTypeId: number, teamId: number) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: eventTypeId,
|
||||
teamId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
scheduleId: true,
|
||||
hosts: {
|
||||
select: {
|
||||
scheduleId: true,
|
||||
userId: true,
|
||||
user: {
|
||||
select: {
|
||||
defaultScheduleId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+220
@@ -505,6 +505,226 @@ describe("Teams Memberships Endpoints", () => {
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
// Auto-accept tests for sub-teams of organizations
|
||||
describe("auto-accept based on email domain for org sub-teams", () => {
|
||||
let orgWithAutoAccept: Team;
|
||||
let subteamWithAutoAccept: Team;
|
||||
let subteamEventType: EventType;
|
||||
let userWithMatchingEmail: User;
|
||||
let userWithUppercaseEmail: User;
|
||||
let userWithMatchingEmailForOverride: User;
|
||||
let userWithNonMatchingEmail: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create org with auto-accept settings
|
||||
orgWithAutoAccept = await teamsRepositoryFixture.create({
|
||||
name: `auto-accept-org-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
// Create organization settings with orgAutoAcceptEmail
|
||||
await teamsRepositoryFixture.createOrgSettings(orgWithAutoAccept.id, {
|
||||
orgAutoAcceptEmail: "acme.com",
|
||||
isOrganizationVerified: true,
|
||||
isOrganizationConfigured: true,
|
||||
isAdminAPIEnabled: true,
|
||||
});
|
||||
|
||||
// Create subteam
|
||||
subteamWithAutoAccept = await teamsRepositoryFixture.create({
|
||||
name: `auto-accept-subteam-${randomString()}`,
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: orgWithAutoAccept.id } },
|
||||
});
|
||||
|
||||
// Create event type with assignAllTeamMembers
|
||||
subteamEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: { connect: { id: subteamWithAutoAccept.id } },
|
||||
title: "Auto Accept Event Type",
|
||||
slug: "auto-accept-event-type",
|
||||
length: 30,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
});
|
||||
|
||||
// Create users with different email domains
|
||||
userWithMatchingEmail = await userRepositoryFixture.create({
|
||||
email: `alice-${randomString()}@acme.com`,
|
||||
username: `alice-${randomString()}`,
|
||||
});
|
||||
|
||||
userWithUppercaseEmail = await userRepositoryFixture.create({
|
||||
email: `bob-${randomString()}@ACME.COM`,
|
||||
username: `bob-${randomString()}`,
|
||||
});
|
||||
|
||||
userWithMatchingEmailForOverride = await userRepositoryFixture.create({
|
||||
email: `david-${randomString()}@acme.com`,
|
||||
username: `david-${randomString()}`,
|
||||
});
|
||||
|
||||
userWithNonMatchingEmail = await userRepositoryFixture.create({
|
||||
email: `charlie-${randomString()}@external.com`,
|
||||
username: `charlie-${randomString()}`,
|
||||
});
|
||||
|
||||
// Add users to org
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
accepted: true,
|
||||
user: { connect: { id: userWithMatchingEmail.id } },
|
||||
team: { connect: { id: orgWithAutoAccept.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
accepted: true,
|
||||
user: { connect: { id: userWithUppercaseEmail.id } },
|
||||
team: { connect: { id: orgWithAutoAccept.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
accepted: true,
|
||||
user: { connect: { id: userWithMatchingEmailForOverride.id } },
|
||||
team: { connect: { id: orgWithAutoAccept.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
accepted: true,
|
||||
user: { connect: { id: userWithNonMatchingEmail.id } },
|
||||
team: { connect: { id: orgWithAutoAccept.id } },
|
||||
});
|
||||
|
||||
// Create profiles for users
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${userWithMatchingEmail.id}`,
|
||||
username: userWithMatchingEmail.username || `user-${userWithMatchingEmail.id}`,
|
||||
organization: { connect: { id: orgWithAutoAccept.id } },
|
||||
user: { connect: { id: userWithMatchingEmail.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${userWithUppercaseEmail.id}`,
|
||||
username: userWithUppercaseEmail.username || `user-${userWithUppercaseEmail.id}`,
|
||||
organization: { connect: { id: orgWithAutoAccept.id } },
|
||||
user: { connect: { id: userWithUppercaseEmail.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${userWithMatchingEmailForOverride.id}`,
|
||||
username:
|
||||
userWithMatchingEmailForOverride.username || `user-${userWithMatchingEmailForOverride.id}`,
|
||||
organization: { connect: { id: orgWithAutoAccept.id } },
|
||||
user: { connect: { id: userWithMatchingEmailForOverride.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${userWithNonMatchingEmail.id}`,
|
||||
username: userWithNonMatchingEmail.username || `user-${userWithNonMatchingEmail.id}`,
|
||||
organization: { connect: { id: orgWithAutoAccept.id } },
|
||||
user: { connect: { id: userWithNonMatchingEmail.id } },
|
||||
});
|
||||
|
||||
// Make teamAdmin an admin of the org and subteam for API access
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
accepted: true,
|
||||
user: { connect: { id: teamAdmin.id } },
|
||||
team: { connect: { id: orgWithAutoAccept.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
accepted: true,
|
||||
user: { connect: { id: teamAdmin.id } },
|
||||
team: { connect: { id: subteamWithAutoAccept.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-org-${teamAdmin.id}`,
|
||||
username: teamAdmin.username || `admin-${teamAdmin.id}`,
|
||||
organization: { connect: { id: orgWithAutoAccept.id } },
|
||||
user: { connect: { id: teamAdmin.id } },
|
||||
});
|
||||
});
|
||||
|
||||
it("should auto-accept when email matches orgAutoAcceptEmail for sub-team", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/teams/${subteamWithAutoAccept.id}/memberships`)
|
||||
.send({
|
||||
userId: userWithMatchingEmail.id,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateTeamMembershipInput)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateTeamMembershipOutput = response.body;
|
||||
expect(responseBody.data.accepted).toBe(true);
|
||||
|
||||
// Verify EventTypes assignment
|
||||
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(subteamWithAutoAccept.id);
|
||||
const eventTypeWithAssignAll = eventTypes.find((et) => et.assignAllTeamMembers);
|
||||
expect(eventTypeWithAssignAll).toBeTruthy();
|
||||
const userIsHost = eventTypeWithAssignAll?.hosts.some((h) => h.userId === userWithMatchingEmail.id);
|
||||
expect(userIsHost).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle case-insensitive email domain matching for sub-team", async () => {
|
||||
// User with email="bob@ACME.COM" should match orgAutoAcceptEmail="acme.com"
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/teams/${subteamWithAutoAccept.id}/memberships`)
|
||||
.send({
|
||||
userId: userWithUppercaseEmail.id,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateTeamMembershipInput)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateTeamMembershipOutput = response.body;
|
||||
expect(responseBody.data.accepted).toBe(true);
|
||||
});
|
||||
|
||||
it("should ALWAYS auto-accept when email matches, even if accepted:false for sub-team", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/teams/${subteamWithAutoAccept.id}/memberships`)
|
||||
.send({
|
||||
userId: userWithMatchingEmailForOverride.id,
|
||||
role: "MEMBER",
|
||||
accepted: false,
|
||||
} satisfies CreateTeamMembershipInput)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateTeamMembershipOutput = response.body;
|
||||
// Should override to true because email matches
|
||||
expect(responseBody.data.accepted).toBe(true);
|
||||
});
|
||||
|
||||
it("should NOT auto-accept when email does not match orgAutoAcceptEmail for sub-team", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/teams/${subteamWithAutoAccept.id}/memberships`)
|
||||
.send({
|
||||
userId: userWithNonMatchingEmail.id,
|
||||
role: "MEMBER",
|
||||
} satisfies CreateTeamMembershipInput)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateTeamMembershipOutput = response.body;
|
||||
expect(responseBody.data.accepted).toBe(false);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(userWithMatchingEmail.email);
|
||||
await userRepositoryFixture.deleteByEmail(userWithUppercaseEmail.email);
|
||||
await userRepositoryFixture.deleteByEmail(userWithMatchingEmailForOverride.email);
|
||||
await userRepositoryFixture.deleteByEmail(userWithNonMatchingEmail.email);
|
||||
await teamsRepositoryFixture.deleteOrgSettings(orgWithAutoAccept.id);
|
||||
await teamsRepositoryFixture.delete(subteamWithAutoAccept.id);
|
||||
await teamsRepositoryFixture.delete(orgWithAutoAccept.id);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(teamAdmin.email);
|
||||
await userRepositoryFixture.deleteByEmail(teammateInvitedViaApi.email);
|
||||
|
||||
+19
-27
@@ -1,3 +1,22 @@
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries/event-types";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_HEADER } from "@/lib/docs/headers";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
@@ -13,26 +32,6 @@ import { GetTeamMembershipsOutput } from "@/modules/teams/memberships/outputs/ge
|
||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||
import { UpdateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/update-team-membership.output";
|
||||
import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service";
|
||||
import {
|
||||
Controller,
|
||||
UseGuards,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
Delete,
|
||||
Patch,
|
||||
Post,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries/event-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/teams/:teamId/memberships",
|
||||
@@ -55,13 +54,6 @@ export class TeamsMembershipsController {
|
||||
@Body() body: CreateTeamMembershipInput
|
||||
): Promise<CreateTeamMembershipOutput> {
|
||||
const membership = await this.teamsMembershipsService.createTeamMembership(teamId, body);
|
||||
if (membership.accepted) {
|
||||
try {
|
||||
await updateNewTeamMemberEventTypes(body.userId, teamId);
|
||||
} catch (err) {
|
||||
this.logger.error("Could not update new team member eventTypes", err);
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { TeamService } from "@calcom/platform-libraries";
|
||||
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries/event-types";
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { OrganizationMembershipService } from "@/lib/services/organization-membership.service";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input";
|
||||
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
|
||||
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { TeamService } from "@calcom/platform-libraries";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
|
||||
export const PLATFORM_USER_BEING_ADDED_TO_REGULAR_TEAM_ERROR = `Can't add user to team - the user is platform managed user but team is not because team probably was not created using OAuth credentials.`;
|
||||
export const REGULAR_USER_BEING_ADDED_TO_PLATFORM_TEAM_ERROR = `Can't add user to team - the user is not platform managed user but team is platform managed. Both have to be created using OAuth credentials.`;
|
||||
@@ -14,11 +17,44 @@ export const PLATFORM_USER_AND_PLATFORM_TEAM_CREATED_WITH_DIFFERENT_OAUTH_CLIENT
|
||||
export class TeamsMembershipsService {
|
||||
constructor(
|
||||
private readonly teamsMembershipsRepository: TeamsMembershipsRepository,
|
||||
private readonly oAuthClientsRepository: OAuthClientRepository
|
||||
private readonly oAuthClientsRepository: OAuthClientRepository,
|
||||
private readonly teamsRepository: TeamsRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly orgMembershipService: OrganizationMembershipService,
|
||||
private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
private async shouldAutoAccept({ teamId, userId }: { teamId: number; userId: number }): Promise<boolean> {
|
||||
const team = await this.teamsRepository.getById(teamId);
|
||||
|
||||
if (team?.parentId) {
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
if (user) {
|
||||
const shouldAutoAccept = await this.orgMembershipService.shouldAutoAccept({
|
||||
organizationId: team.parentId,
|
||||
userEmail: user.email,
|
||||
});
|
||||
|
||||
return shouldAutoAccept;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
async createTeamMembership(teamId: number, data: CreateTeamMembershipInput) {
|
||||
await this.canUserBeAddedToTeam(data.userId, teamId);
|
||||
const shouldAutoAccept = await this.shouldAutoAccept({ teamId, userId: data.userId });
|
||||
if (shouldAutoAccept) {
|
||||
data = { ...data, accepted: true };
|
||||
}
|
||||
|
||||
if (data.accepted) {
|
||||
try {
|
||||
await updateNewTeamMemberEventTypes(data.userId, teamId);
|
||||
} catch (err) {
|
||||
this.logger.error("Could not update new team member eventTypes", err);
|
||||
}
|
||||
}
|
||||
const teamMembership = await this.teamsMembershipsRepository.createTeamMembership(teamId, data);
|
||||
return teamMembership;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,21 @@ import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-t
|
||||
import { TeamsMembershipsController } from "@/modules/teams/memberships/controllers/teams-memberships.controller";
|
||||
import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service";
|
||||
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Logger, Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, RedisModule, OrganizationsModule, MembershipsModule, TeamsEventTypesModule],
|
||||
providers: [TeamsMembershipsRepository, TeamsMembershipsService],
|
||||
imports: [
|
||||
PrismaModule,
|
||||
RedisModule,
|
||||
OrganizationsModule,
|
||||
MembershipsModule,
|
||||
TeamsEventTypesModule,
|
||||
TeamsModule,
|
||||
UsersModule,
|
||||
],
|
||||
providers: [TeamsMembershipsRepository, TeamsMembershipsService, Logger],
|
||||
controllers: [TeamsMembershipsController],
|
||||
exports: [TeamsMembershipsService],
|
||||
})
|
||||
|
||||
@@ -1,20 +1,73 @@
|
||||
import type { ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
|
||||
import type { Availability, Schedule } from "@calcom/prisma/client";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class TeamsSchedulesService {
|
||||
constructor(
|
||||
private readonly teamsRepository: TeamsRepository,
|
||||
private readonly schedulesRepository: SchedulesRepository_2024_06_11,
|
||||
private readonly outputSchedulesService: OutputSchedulesService_2024_06_11
|
||||
private readonly outputSchedulesService: OutputSchedulesService_2024_06_11,
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
|
||||
) {}
|
||||
|
||||
async getTeamSchedules(teamId: number, skip = 0, take = 250) {
|
||||
const userIds = await this.teamsRepository.getTeamUsersIds(teamId);
|
||||
const schedules = await this.schedulesRepository.getSchedulesByUserIds(userIds, skip, take);
|
||||
async getTeamSchedules(
|
||||
teamId: number,
|
||||
skip = 0,
|
||||
take = 250,
|
||||
eventTypeId?: number
|
||||
): Promise<ScheduleOutput_2024_06_11[]> {
|
||||
if (!eventTypeId) {
|
||||
const userIds = await this.teamsRepository.getTeamUsersIds(teamId);
|
||||
const schedules = await this.schedulesRepository.getSchedulesByUserIds(userIds, skip, take);
|
||||
|
||||
return this.outputSchedulesService.getResponseSchedules(schedules);
|
||||
return this.outputSchedulesService.getResponseSchedules(schedules);
|
||||
}
|
||||
|
||||
return this.getTeamSchedulesForEventType(teamId, eventTypeId, skip, take);
|
||||
}
|
||||
|
||||
private async getTeamSchedulesForEventType(
|
||||
teamId: number,
|
||||
eventTypeId: number,
|
||||
skip: number,
|
||||
take: number
|
||||
): Promise<ScheduleOutput_2024_06_11[]> {
|
||||
const eventType = await this.teamsEventTypesRepository.getByIdIncludeHostsAndUserDefaultSchedule(
|
||||
eventTypeId,
|
||||
teamId
|
||||
);
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found in team ${teamId}`);
|
||||
}
|
||||
|
||||
const effectiveScheduleIds = new Set<number>();
|
||||
|
||||
for (const host of eventType.hosts) {
|
||||
const effectiveScheduleId = eventType.scheduleId ?? host.scheduleId ?? host.user.defaultScheduleId;
|
||||
|
||||
if (effectiveScheduleId) {
|
||||
effectiveScheduleIds.add(effectiveScheduleId);
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveScheduleIds.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const schedules = await this.schedulesRepository.getSchedulesByIds(
|
||||
Array.from(effectiveScheduleIds),
|
||||
skip,
|
||||
take
|
||||
);
|
||||
|
||||
return this.outputSchedulesService.getResponseSchedules(
|
||||
schedules as (Schedule & { availability: Availability[] })[]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import { OrganizationSchedulesRepository } from "@/modules/organizations/schedul
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service";
|
||||
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
|
||||
import { TeamsSchedulesController } from "@/modules/teams/schedules/controllers/teams-schedules.controller";
|
||||
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
|
||||
@@ -20,12 +20,11 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, MembershipsModule, RedisModule],
|
||||
imports: [PrismaModule, MembershipsModule, RedisModule, TeamsEventTypesModule],
|
||||
providers: [
|
||||
TeamsRepository,
|
||||
TeamsService,
|
||||
TeamsMembershipsRepository,
|
||||
TeamsMembershipsService,
|
||||
OutputSchedulesService_2024_06_11,
|
||||
OrganizationSchedulesRepository,
|
||||
StripeService,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service";
|
||||
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
|
||||
import { TeamsController } from "@/modules/teams/teams/controllers/teams.controller";
|
||||
import { TeamsService } from "@/modules/teams/teams/services/teams.service";
|
||||
@@ -11,7 +10,7 @@ import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, MembershipsModule, RedisModule, StripeModule],
|
||||
providers: [TeamsRepository, TeamsService, TeamsMembershipsRepository, TeamsMembershipsService],
|
||||
providers: [TeamsRepository, TeamsService, TeamsMembershipsRepository],
|
||||
controllers: [TeamsController],
|
||||
exports: [TeamsRepository],
|
||||
})
|
||||
|
||||
@@ -33,4 +33,30 @@ export class TeamRepositoryFixture {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createOrgSettings(
|
||||
organizationId: number,
|
||||
settings: {
|
||||
orgAutoAcceptEmail: string;
|
||||
isOrganizationVerified?: boolean;
|
||||
isOrganizationConfigured?: boolean;
|
||||
isAdminAPIEnabled?: boolean;
|
||||
}
|
||||
) {
|
||||
return this.prismaWriteClient.organizationSettings.create({
|
||||
data: {
|
||||
organizationId,
|
||||
orgAutoAcceptEmail: settings.orgAutoAcceptEmail,
|
||||
isOrganizationVerified: settings.isOrganizationVerified,
|
||||
isOrganizationConfigured: settings.isOrganizationConfigured,
|
||||
isAdminAPIEnabled: settings.isAdminAPIEnabled,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteOrgSettings(organizationId: number) {
|
||||
return this.prismaWriteClient.organizationSettings.deleteMany({
|
||||
where: { organizationId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,14 @@ export function ShellMainAppDir(props: LayoutProps) {
|
||||
{!!props.backPath && <ShellMainAppDirBackButton backPath={props.backPath} />}
|
||||
{props.heading && (
|
||||
<header
|
||||
className={classNames(props.large && "py-8", "flex w-full max-w-full items-center truncate")}>
|
||||
className={classNames(
|
||||
props.large && "py-8",
|
||||
"flex w-full max-w-full items-center flex-wrap md:flex-nowrap gap-2 md:gap-0"
|
||||
)}>
|
||||
{props.HeadingLeftIcon && <div className="ltr:mr-4">{props.HeadingLeftIcon}</div>}
|
||||
<div
|
||||
className={classNames(
|
||||
"hidden w-full truncate ltr:mr-4 rtl:ml-4 md:block",
|
||||
"hidden min-w-0 flex-1 ltr:mr-4 rtl:ml-4 md:block",
|
||||
props.headerClassName
|
||||
)}>
|
||||
{props.heading && (
|
||||
|
||||
+4
@@ -22,6 +22,7 @@ import { HOSTED_CAL_FEATURES, IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constant
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useIsStandalone } from "@calcom/lib/hooks/useIsStandalone";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { IdentityProvider, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -1012,6 +1013,9 @@ const SettingsSidebarContainer = ({
|
||||
const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const isStandalone = useIsStandalone();
|
||||
|
||||
if (isStandalone) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import LicenseRequired from "~/ee/common/components/LicenseRequired";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import LegacyPage, { LayoutWrapper } from "~/settings/platform/new/create-new-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("set_up_your_platform_organization"),
|
||||
(t) => t("platform_organization_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/platform/new"
|
||||
);
|
||||
|
||||
const ServerPage = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
const callbackUrl = `${WEBAPP_URL}/settings/platform/new`;
|
||||
|
||||
if (!session?.user) {
|
||||
redirect(`/login?callbackUrl=${callbackUrl}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<LayoutWrapper>
|
||||
<LicenseRequired>
|
||||
<LegacyPage />
|
||||
</LicenseRequired>
|
||||
</LayoutWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { TeamInviteEmailView } from "~/settings/teams/new/invite/email/team-invite-email-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("invite")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/settings/teams/new/invite/email"
|
||||
);
|
||||
};
|
||||
|
||||
const ServerPage = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const userEmail = session.user.email || "";
|
||||
|
||||
return <TeamInviteEmailView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { TeamInviteView } from "~/settings/teams/new/invite/team-invite-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
(t) => `${APP_NAME} - ${t("invite")}`,
|
||||
() => "",
|
||||
true,
|
||||
undefined,
|
||||
"/settings/teams/new/invite"
|
||||
);
|
||||
};
|
||||
|
||||
const ServerPage = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const userEmail = session.user.email || "";
|
||||
|
||||
return <TeamInviteView userEmail={userEmail} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -1,6 +1,12 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import CreateNewTeamView, { LayoutWrapper } from "~/settings/teams/new/create-new-team-view";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { CreateNewTeamView, LayoutWrapper } from "~/settings/teams/new/create-new-team-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -12,9 +18,17 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
const ServerPage = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const userEmail = session.user.email || "";
|
||||
|
||||
return (
|
||||
<LayoutWrapper>
|
||||
<CreateNewTeamView />
|
||||
<CreateNewTeamView userEmail={userEmail} />
|
||||
</LayoutWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
|
||||
import { shouldEnableApp } from "@calcom/app-store/_utils/validateAppKeys";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { AppCategories, Prisma } from "@calcom/prisma/client";
|
||||
@@ -48,6 +49,17 @@ async function postHandler(request: NextRequest) {
|
||||
updates["dirName"] = app.dirName ?? app.slug;
|
||||
}
|
||||
|
||||
// Ensure app is only enabled if it has valid keys (or doesn't require keys)
|
||||
const shouldBeEnabled = shouldEnableApp(dbApp.dirName, dbApp.keys);
|
||||
if (dbApp.enabled !== shouldBeEnabled) {
|
||||
updates["enabled"] = shouldBeEnabled;
|
||||
if (!shouldBeEnabled && dbApp.enabled) {
|
||||
log.warn(
|
||||
`⚠️ Disabling app ${dbApp.slug} - required keys are missing or invalid. Please configure keys in admin settings.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
log.info(`🔨 Updating app ${dbApp.slug} with ${Object.keys(updates).join(", ")}`);
|
||||
if (!isDryRun) {
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
import process from "node:process";
|
||||
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
||||
import { useIsPlatformBookerEmbed } from "@calcom/atoms/hooks/useIsPlatformBookerEmbed";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useEmbedUiConfig } from "@calcom/embed-core/embed-iframe";
|
||||
import { updateEmbedBookerState } from "@calcom/embed-core/src/embed-iframe";
|
||||
import TurnstileCaptcha from "@calcom/features/auth/Turnstile";
|
||||
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
|
||||
import { useIsQuickAvailabilityCheckFeatureEnabled } from "@calcom/features/bookings/Booker/components/hooks/useIsQuickAvailabilityCheckFeatureEnabled";
|
||||
import useSkipConfirmStep from "@calcom/features/bookings/Booker/components/hooks/useSkipConfirmStep";
|
||||
import {
|
||||
fadeInLeft,
|
||||
getBookerSizeClassNames,
|
||||
useBookerResizeAnimation,
|
||||
} from "@calcom/features/bookings/Booker/config";
|
||||
import framerFeatures from "@calcom/features/bookings/Booker/framer-features";
|
||||
import type { BookerProps, WrappedBookerProps } from "@calcom/features/bookings/Booker/types";
|
||||
import { isBookingDryRun } from "@calcom/features/bookings/Booker/utils/isBookingDryRun";
|
||||
import { isTimeSlotAvailable } from "@calcom/features/bookings/Booker/utils/isTimeslotAvailable";
|
||||
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
|
||||
import { useNonEmptyScheduleDays } from "@calcom/features/schedules/lib/use-schedule/useNonEmptyScheduleDays";
|
||||
import { scrollIntoViewSmooth } from "@calcom/lib/browser/browser.utils";
|
||||
import {
|
||||
CLOUDFLARE_SITE_ID,
|
||||
CLOUDFLARE_USE_TURNSTILE_IN_BOOKER,
|
||||
PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM,
|
||||
} from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { BookerLayouts } from "@calcom/prisma/zod-utils";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { UnpublishedEntity } from "@calcom/ui/components/unpublished-entity";
|
||||
import PoweredBy from "@calcom/web/modules/ee/common/components/PoweredBy";
|
||||
import { AnimatePresence, LazyMotion, m } from "framer-motion";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import StickyBox from "react-sticky-box";
|
||||
import { Toaster } from "sonner";
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
||||
import { useIsPlatformBookerEmbed } from "@calcom/atoms/hooks/useIsPlatformBookerEmbed";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import PoweredBy from "@calcom/web/modules/ee/common/components/PoweredBy";
|
||||
import { useEmbedUiConfig } from "@calcom/embed-core/embed-iframe";
|
||||
import { updateEmbedBookerState } from "@calcom/embed-core/src/embed-iframe";
|
||||
import TurnstileCaptcha from "@calcom/features/auth/Turnstile";
|
||||
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
|
||||
import useSkipConfirmStep from "@calcom/features/bookings/Booker/components/hooks/useSkipConfirmStep";
|
||||
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
|
||||
import { useNonEmptyScheduleDays } from "@calcom/features/schedules/lib/use-schedule/useNonEmptyScheduleDays";
|
||||
import { scrollIntoViewSmooth } from "@calcom/lib/browser/browser.utils";
|
||||
import { PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM } from "@calcom/lib/constants";
|
||||
import { CLOUDFLARE_SITE_ID, CLOUDFLARE_USE_TURNSTILE_IN_BOOKER } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { BookerLayouts } from "@calcom/prisma/zod-utils";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { UnpublishedEntity } from "@calcom/ui/components/unpublished-entity";
|
||||
|
||||
import { VerifyCodeDialog } from "./VerifyCodeDialog";
|
||||
import { AvailableTimeSlots } from "./AvailableTimeSlots";
|
||||
import { BookEventForm } from "./BookEventForm";
|
||||
import { BookFormAsModal } from "./BookEventForm/BookFormAsModal";
|
||||
@@ -38,12 +49,7 @@ import { OverlayCalendar } from "./OverlayCalendar/OverlayCalendar";
|
||||
import { RedirectToInstantMeetingModal } from "./RedirectToInstantMeetingModal";
|
||||
import { BookerSection } from "./Section";
|
||||
import { NotFound } from "./Unavailable";
|
||||
import { useIsQuickAvailabilityCheckFeatureEnabled } from "@calcom/features/bookings/Booker/components/hooks/useIsQuickAvailabilityCheckFeatureEnabled";
|
||||
import { fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from "@calcom/features/bookings/Booker/config";
|
||||
import framerFeatures from "@calcom/features/bookings/Booker/framer-features";
|
||||
import type { BookerProps, WrappedBookerProps } from "@calcom/features/bookings/Booker/types";
|
||||
import { isBookingDryRun } from "@calcom/features/bookings/Booker/utils/isBookingDryRun";
|
||||
import { isTimeSlotAvailable } from "@calcom/features/bookings/Booker/utils/isTimeslotAvailable";
|
||||
import { VerifyCodeDialog } from "./VerifyCodeDialog";
|
||||
|
||||
const BookerComponent = ({
|
||||
username,
|
||||
@@ -219,16 +225,24 @@ const BookerComponent = ({
|
||||
if (selectedTimeslot && skipConfirmStep && isSkipConfirmStepSupported)
|
||||
return setBookerState("selecting_time");
|
||||
return setBookerState("booking");
|
||||
}, [event, selectedDate, selectedTimeslot, setBookerState, skipConfirmStep, layout, isInstantMeeting]);
|
||||
}, [
|
||||
event.isPending,
|
||||
selectedDate,
|
||||
selectedTimeslot,
|
||||
setBookerState,
|
||||
skipConfirmStep,
|
||||
layout,
|
||||
isInstantMeeting,
|
||||
]);
|
||||
|
||||
const unavailableTimeSlots = isQuickAvailabilityCheckFeatureEnabled
|
||||
? allSelectedTimeslots.filter((slot) => {
|
||||
return !isTimeSlotAvailable({
|
||||
scheduleData: schedule?.data ?? null,
|
||||
slotToCheckInIso: slot,
|
||||
quickAvailabilityChecks: slots.quickAvailabilityChecks,
|
||||
});
|
||||
})
|
||||
return !isTimeSlotAvailable({
|
||||
scheduleData: schedule?.data ?? null,
|
||||
slotToCheckInIso: slot,
|
||||
quickAvailabilityChecks: slots.quickAvailabilityChecks,
|
||||
});
|
||||
})
|
||||
: [];
|
||||
|
||||
const slot = getQueryParam("slot");
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnFilterType, DataTableProvider, type SystemFilterSegment } from "@calcom/features/data-table";
|
||||
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
|
||||
import FeatureOptInBannerWrapper from "@calcom/features/feature-opt-in/components/FeatureOptInBannerWrapper";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import dynamic from "next/dynamic";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { DataTableProvider, type SystemFilterSegment, ColumnFilterType } from "@calcom/features/data-table";
|
||||
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
|
||||
import { useFeatureOptInBanner } from "../../feature-opt-in/hooks/useFeatureOptInBanner";
|
||||
import { BookingListContainer } from "../components/BookingListContainer";
|
||||
import { useBookingsShellHeadingVisibility } from "../hooks/useBookingsShellHeadingVisibility";
|
||||
import { useBookingsView } from "../hooks/useBookingsView";
|
||||
@@ -71,6 +72,7 @@ export default function Bookings(props: BookingsProps) {
|
||||
|
||||
function BookingsContent({ status, permissions, bookingsV3Enabled, bookingAuditEnabled }: BookingsProps) {
|
||||
const [view] = useBookingsView({ bookingsV3Enabled });
|
||||
const optInBanner = useFeatureOptInBanner("bookings-v3");
|
||||
|
||||
useBookingsShellHeadingVisibility({ visible: view === "list" });
|
||||
|
||||
@@ -91,6 +93,7 @@ function BookingsContent({ status, permissions, bookingsV3Enabled, bookingAuditE
|
||||
bookingsV3Enabled={bookingsV3Enabled}
|
||||
/>
|
||||
)}
|
||||
<FeatureOptInBannerWrapper state={optInBanner} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { FeaturesSettings } from "@calcom/features/feature-opt-in/components/FeaturesSettings";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { useOrganizationFeatureOptIn } from "~/feature-opt-in/hooks";
|
||||
import { useOrganizationFeatureOptIn } from "~/feature-opt-in/hooks/useOrganizationFeatureOptIn";
|
||||
|
||||
interface OrganizationFeaturesViewProps {
|
||||
canEdit: boolean;
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
import { useMemo, type ComponentProps, type Dispatch, type SetStateAction } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
import type { Options } from "react-select";
|
||||
|
||||
import { AddMembersWithSwitchPlatformWrapper } from "@calcom/atoms/add-members-switch/AddMembersWithSwitchPlatformWrapper";
|
||||
import { AddMembersWithSwitchWebWrapper } from "./AddMembersWithSwitchWebWrapper";
|
||||
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
|
||||
import { Segment } from "@calcom/features/Segment";
|
||||
import type {
|
||||
FormValues,
|
||||
Host,
|
||||
SettingsToggleClassNames,
|
||||
TeamMember,
|
||||
} from "@calcom/features/eventtypes/lib/types";
|
||||
import { Segment } from "@calcom/features/Segment";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { AttributesQueryValue } from "@calcom/lib/raqb/types";
|
||||
import { Label } from "@calcom/ui/components/form";
|
||||
import { SettingsToggle } from "@calcom/ui/components/form";
|
||||
import { Label, SettingsToggle } from "@calcom/ui/components/form";
|
||||
import { type ComponentProps, type Dispatch, type SetStateAction, useMemo } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { Options } from "react-select";
|
||||
import { AddMembersWithSwitchWebWrapper } from "./AddMembersWithSwitchWebWrapper";
|
||||
|
||||
import AssignAllTeamMembers from "./AssignAllTeamMembers";
|
||||
import CheckedTeamSelect from "./CheckedTeamSelect";
|
||||
import type { CheckedSelectOption, CheckedTeamSelectCustomClassNames } from "./CheckedTeamSelect";
|
||||
import CheckedTeamSelect from "./CheckedTeamSelect";
|
||||
|
||||
interface IUserToValue {
|
||||
id: number | null;
|
||||
@@ -130,6 +127,7 @@ function MembersSegmentWithToggle({
|
||||
rrSegmentQueryValue,
|
||||
setRrSegmentQueryValue,
|
||||
className,
|
||||
filterMemberIds,
|
||||
}: {
|
||||
teamId: number;
|
||||
assignRRMembersUsingSegment: boolean;
|
||||
@@ -137,6 +135,7 @@ function MembersSegmentWithToggle({
|
||||
rrSegmentQueryValue: AttributesQueryValue | null;
|
||||
setRrSegmentQueryValue: (value: AttributesQueryValue) => void;
|
||||
className?: string;
|
||||
filterMemberIds?: number[];
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const onQueryValueChange = ({ queryValue }: { queryValue: AttributesQueryValue }) => {
|
||||
@@ -162,6 +161,7 @@ function MembersSegmentWithToggle({
|
||||
queryValue={rrSegmentQueryValue}
|
||||
onQueryValueChange={onQueryValueChange}
|
||||
className={className}
|
||||
filterMemberIds={filterMemberIds}
|
||||
/>
|
||||
)}
|
||||
</SettingsToggle>
|
||||
@@ -193,7 +193,7 @@ export type AddMembersWithSwitchProps = {
|
||||
customClassNames?: AddMembersWithSwitchCustomClassNames;
|
||||
};
|
||||
|
||||
const enum AssignmentState {
|
||||
enum AssignmentState {
|
||||
TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_NOT_APPLICABLE = "TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_NOT_APPLICABLE",
|
||||
TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE = "TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE",
|
||||
ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_APPLICABLE = "ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_APPLICABLE",
|
||||
@@ -303,6 +303,7 @@ export function AddMembersWithSwitch({
|
||||
setAssignRRMembersUsingSegment={setAssignRRMembersUsingSegment}
|
||||
rrSegmentQueryValue={rrSegmentQueryValue}
|
||||
setRrSegmentQueryValue={setRrSegmentQueryValue}
|
||||
filterMemberIds={value.filter((host) => !host.isFixed).map((host) => host.userId)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
|
||||
import { useTeamMembersWithSegmentPlatform } from "@calcom/atoms/event-types/hooks/useTeamMembersWithSegmentPlatform";
|
||||
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
|
||||
import type { Host, TeamMember } from "@calcom/features/eventtypes/lib/types";
|
||||
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
||||
@@ -10,8 +8,7 @@ import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { AttributesQueryValue } from "@calcom/lib/raqb/types";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { buttonClasses } from "@calcom/ui/components/button";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Button, buttonClasses } from "@calcom/ui/components/button";
|
||||
import { TextField } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import {
|
||||
@@ -24,11 +21,9 @@ import {
|
||||
SheetTitle,
|
||||
} from "@calcom/ui/components/sheet";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import {
|
||||
useTeamMembersWithSegmentPlatform,
|
||||
} from "@calcom/atoms/event-types/hooks/useTeamMembersWithSegmentPlatform";
|
||||
import { useTeamMembersWithSegment } from "@calcom/web/modules/event-types/hooks/useTeamMembersWithSegment";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type TeamMemberItemProps = {
|
||||
member: Omit<TeamMember, "defaultScheduleId"> & { weight?: number };
|
||||
@@ -102,7 +97,6 @@ interface Props {
|
||||
teamMembers: TeamMember[];
|
||||
value: Host[];
|
||||
onChange: (hosts: Host[]) => void;
|
||||
assignAllTeamMembers: boolean;
|
||||
assignRRMembersUsingSegment: boolean;
|
||||
teamId?: number;
|
||||
queryValue?: AttributesQueryValue | null;
|
||||
@@ -112,7 +106,6 @@ export const EditWeightsForAllTeamMembers = ({
|
||||
teamMembers: initialTeamMembers,
|
||||
value,
|
||||
onChange,
|
||||
assignAllTeamMembers,
|
||||
assignRRMembersUsingSegment,
|
||||
teamId,
|
||||
queryValue,
|
||||
@@ -240,13 +233,9 @@ export const EditWeightsForAllTeamMembers = ({
|
||||
member.email.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
.filter((member) => {
|
||||
// When assignAllTeamMembers is false, only include members that exist in value array
|
||||
return (
|
||||
assignAllTeamMembers ||
|
||||
value.some((host) => !host.isFixed && host.userId === parseInt(member.value, 10))
|
||||
);
|
||||
return value.some((host) => !host.isFixed && host.userId === parseInt(member.value, 10));
|
||||
});
|
||||
}, [teamMembers, localWeights, searchQuery, assignAllTeamMembers, value]);
|
||||
}, [teamMembers, localWeights, searchQuery, value]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
+43
-49
@@ -1,11 +1,21 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ComponentProps, Dispatch, SetStateAction } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import type { Options } from "react-select";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import type {
|
||||
EventTypeSetupProps,
|
||||
FormValues,
|
||||
Host,
|
||||
SelectClassNames,
|
||||
SettingsToggleClassNames,
|
||||
TeamMember,
|
||||
} from "@calcom/features/eventtypes/lib/types";
|
||||
import { sortHosts } from "@calcom/lib/bookings/hostGroupUtils";
|
||||
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RRTimestampBasis, SchedulingType } from "@calcom/prisma/enums";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label, Select, SettingsToggle } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { RadioAreaGroup as RadioArea } from "@calcom/ui/components/radio";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
import type { AddMembersWithSwitchCustomClassNames } from "@calcom/web/modules/event-types/components/AddMembersWithSwitch";
|
||||
import AddMembersWithSwitch, {
|
||||
mapUserToValue,
|
||||
@@ -14,28 +24,15 @@ import AssignAllTeamMembers from "@calcom/web/modules/event-types/components/Ass
|
||||
import type { ChildrenEventTypeSelectCustomClassNames } from "@calcom/web/modules/event-types/components/ChildrenEventTypeSelect";
|
||||
import ChildrenEventTypeSelect from "@calcom/web/modules/event-types/components/ChildrenEventTypeSelect";
|
||||
import { EditWeightsForAllTeamMembers } from "@calcom/web/modules/event-types/components/EditWeightsForAllTeamMembers";
|
||||
import { sortHosts } from "@calcom/lib/bookings/hostGroupUtils";
|
||||
import { LearnMoreLink } from "@calcom/web/modules/event-types/components/LearnMoreLink";
|
||||
import WeightDescription from "@calcom/web/modules/event-types/components/WeightDescription";
|
||||
import type {
|
||||
FormValues,
|
||||
TeamMember,
|
||||
EventTypeSetupProps,
|
||||
Host,
|
||||
SelectClassNames,
|
||||
SettingsToggleClassNames,
|
||||
} from "@calcom/features/eventtypes/lib/types";
|
||||
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RRTimestampBasis, SchedulingType } from "@calcom/prisma/enums";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label } from "@calcom/ui/components/form";
|
||||
import { Select } from "@calcom/ui/components/form";
|
||||
import { SettingsToggle } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { RadioAreaGroup as RadioArea } from "@calcom/ui/components/radio";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
import type { TFunction } from "i18next";
|
||||
import Link from "next/link";
|
||||
import type { ComponentProps, Dispatch, SetStateAction } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import type { Options } from "react-select";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export type EventTeamAssignmentTabCustomClassNames = {
|
||||
assignmentType?: {
|
||||
@@ -506,7 +503,6 @@ const RoundRobinHosts = ({
|
||||
teamMembers={teamMembers}
|
||||
value={value}
|
||||
onChange={handleWeightsChange}
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
assignRRMembersUsingSegment={assignRRMembersUsingSegment}
|
||||
teamId={teamId}
|
||||
queryValue={rrSegmentQueryValue}
|
||||
@@ -666,10 +662,10 @@ const Hosts = ({
|
||||
|
||||
return existingHost
|
||||
? {
|
||||
...newValue,
|
||||
scheduleId: existingHost.scheduleId,
|
||||
groupId: existingHost.groupId,
|
||||
}
|
||||
...newValue,
|
||||
scheduleId: existingHost.scheduleId,
|
||||
groupId: existingHost.groupId,
|
||||
}
|
||||
: newValue;
|
||||
});
|
||||
};
|
||||
@@ -724,7 +720,7 @@ const Hosts = ({
|
||||
),
|
||||
MANAGED: <></>,
|
||||
};
|
||||
return !!schedulingType ? schedulingTypeRender[schedulingType] : <></>;
|
||||
return schedulingType ? schedulingTypeRender[schedulingType] : <></>;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -745,17 +741,17 @@ export const EventTeamAssignmentTab = ({
|
||||
label: string;
|
||||
// description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "COLLECTIVE",
|
||||
label: t("collective"),
|
||||
// description: t("collective_description"),
|
||||
},
|
||||
{
|
||||
value: "ROUND_ROBIN",
|
||||
label: t("round_robin"),
|
||||
// description: t("round_robin_description"),
|
||||
},
|
||||
];
|
||||
{
|
||||
value: "COLLECTIVE",
|
||||
label: t("collective"),
|
||||
// description: t("collective_description"),
|
||||
},
|
||||
{
|
||||
value: "ROUND_ROBIN",
|
||||
label: t("round_robin"),
|
||||
// description: t("round_robin_description"),
|
||||
},
|
||||
];
|
||||
const pendingMembers = (member: (typeof teamMembers)[number]) =>
|
||||
!!eventType.team?.parentId || !!member.username;
|
||||
const teamMembersOptions = teamMembers
|
||||
@@ -887,13 +883,11 @@ export const EventTeamAssignmentTab = ({
|
||||
</RadioArea.Item>
|
||||
{(eventType.team?.rrTimestampBasis &&
|
||||
eventType.team?.rrTimestampBasis !== RRTimestampBasis.CREATED_AT) ||
|
||||
hostGroups?.length > 1 ? (
|
||||
hostGroups?.length > 1 ? (
|
||||
<Tooltip
|
||||
content={
|
||||
!!(
|
||||
eventType.team?.rrTimestampBasis &&
|
||||
eventType.team?.rrTimestampBasis &&
|
||||
eventType.team?.rrTimestampBasis !== RRTimestampBasis.CREATED_AT
|
||||
)
|
||||
? t("rr_load_balancing_disabled")
|
||||
: t("rr_load_balancing_disabled_with_groups")
|
||||
}>
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
|
||||
import type { MultiValue } from "react-select";
|
||||
|
||||
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import type { LocationCustomClassNames } from "../../locations/types";
|
||||
import type {
|
||||
EventTypeSetupProps,
|
||||
FormValues,
|
||||
InputClassNames,
|
||||
LocationFormValues,
|
||||
SelectClassNames,
|
||||
SettingsToggleClassNames,
|
||||
} from "@calcom/features/eventtypes/lib/types";
|
||||
import type { FormValues, LocationFormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { MAX_EVENT_DURATION_MINUTES, MIN_EVENT_DURATION_MINUTES } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
@@ -20,14 +15,14 @@ import { slugify } from "@calcom/lib/slugify";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Editor } from "@calcom/ui/components/editor";
|
||||
import { TextAreaField } from "@calcom/ui/components/form";
|
||||
import { Label } from "@calcom/ui/components/form";
|
||||
import { TextField } from "@calcom/ui/components/form";
|
||||
import { Select } from "@calcom/ui/components/form";
|
||||
import { SettingsToggle } from "@calcom/ui/components/form";
|
||||
import { Label, Select, SettingsToggle, TextAreaField, TextField } from "@calcom/ui/components/form";
|
||||
import { Skeleton } from "@calcom/ui/components/skeleton";
|
||||
|
||||
import Locations from "@calcom/web/modules/event-types/components/locations/Locations";
|
||||
import { useState } from "react";
|
||||
import type { Control, FormState, UseFormGetValues, UseFormSetValue } from "react-hook-form";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { MultiValue } from "react-select";
|
||||
import type { LocationCustomClassNames } from "../../locations/types";
|
||||
|
||||
export type EventSetupTabCustomClassNames = {
|
||||
wrapper?: string;
|
||||
@@ -80,7 +75,7 @@ export const EventSetupTab = (
|
||||
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
||||
|
||||
const multipleDurationOptions = [
|
||||
5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480,
|
||||
5, 10, 15, 20, 25, 30, 40, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480,
|
||||
].map((mins) => ({
|
||||
value: mins,
|
||||
label: t("multiple_duration_mins", { count: mins }),
|
||||
@@ -109,7 +104,7 @@ export const EventSetupTab = (
|
||||
<div className={classNames("stack-y-4", customClassNames?.wrapper)}>
|
||||
<div
|
||||
className={classNames(
|
||||
"border-subtle stack-y-6 rounded-lg border p-6",
|
||||
"stack-y-6 rounded-lg border border-subtle p-6",
|
||||
customClassNames?.titleSection?.container
|
||||
)}>
|
||||
<TextField
|
||||
@@ -169,7 +164,7 @@ export const EventSetupTab = (
|
||||
className={classNames("pl-0", customClassNames?.titleSection?.urlInput?.input)}
|
||||
addOnLeading={
|
||||
isPlatform ? undefined : (
|
||||
<span className="max-w-24 md:max-w-56 inline-block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<span className="inline-block min-w-0 max-w-24 overflow-hidden text-ellipsis whitespace-nowrap md:max-w-56">
|
||||
{urlPrefix}/
|
||||
{!isManagedEventType
|
||||
? team
|
||||
@@ -187,7 +182,7 @@ export const EventSetupTab = (
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"border-subtle rounded-lg border p-6",
|
||||
"rounded-lg border border-subtle p-6",
|
||||
customClassNames?.durationSection?.container
|
||||
)}>
|
||||
{multipleDuration ? (
|
||||
@@ -212,7 +207,7 @@ export const EventSetupTab = (
|
||||
isSearchable={false}
|
||||
isDisabled={lengthLockedProps.disabled}
|
||||
className={classNames(
|
||||
"min-h-[36px]! h-auto text-sm",
|
||||
"h-auto min-h-[36px]! text-sm",
|
||||
customClassNames?.durationSection?.multipleDuration?.availableDurationsSelect?.select
|
||||
)}
|
||||
innerClassNames={
|
||||
@@ -306,7 +301,7 @@ export const EventSetupTab = (
|
||||
message: t("duration_max_error", { max: MAX_EVENT_DURATION_MINUTES }),
|
||||
},
|
||||
})}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
addOnSuffix={t("minutes")}
|
||||
min={MIN_EVENT_DURATION_MINUTES}
|
||||
max={MAX_EVENT_DURATION_MINUTES}
|
||||
/>
|
||||
@@ -341,7 +336,7 @@ export const EventSetupTab = (
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"border-subtle rounded-lg border p-6",
|
||||
"rounded-lg border border-subtle p-6",
|
||||
customClassNames?.locationSection?.container
|
||||
)}>
|
||||
<div>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export { useUserFeatureOptIn } from "./useUserFeatureOptIn";
|
||||
export { useTeamFeatureOptIn } from "./useTeamFeatureOptIn";
|
||||
export { useOrganizationFeatureOptIn } from "./useOrganizationFeatureOptIn";
|
||||
export type { UseFeatureOptInResult, NormalizedFeature, ToggleLabels } from "@calcom/features/feature-opt-in/types";
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import type { OptInFeatureConfig } from "@calcom/features/feature-opt-in/config";
|
||||
import { getOptInFeatureConfig } from "@calcom/features/feature-opt-in/config";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
const DISMISSED_STORAGE_KEY = "feature-opt-in-dismissed";
|
||||
const OPTED_IN_STORAGE_KEY = "feature-opt-in-enabled";
|
||||
|
||||
const featuresMapSchema = z.record(z.string(), z.boolean());
|
||||
|
||||
type FeaturesMap = z.infer<typeof featuresMapSchema>;
|
||||
|
||||
type UserRoleContext = {
|
||||
isOrgAdmin: boolean;
|
||||
orgId: number | null;
|
||||
adminTeamIds: number[];
|
||||
adminTeamNames: { id: number; name: string }[];
|
||||
};
|
||||
|
||||
export type FeatureOptInMutations = {
|
||||
setUserState: (params: { slug: string; state: "enabled" }) => Promise<unknown>;
|
||||
setTeamState: (params: { teamId: number; slug: string; state: "enabled" }) => Promise<unknown>;
|
||||
setOrganizationState: (params: { slug: string; state: "enabled" }) => Promise<unknown>;
|
||||
setUserAutoOptIn: (params: { autoOptIn: boolean }) => Promise<unknown>;
|
||||
setTeamAutoOptIn: (params: { teamId: number; autoOptIn: boolean }) => Promise<unknown>;
|
||||
setOrganizationAutoOptIn: (params: { autoOptIn: boolean }) => Promise<unknown>;
|
||||
invalidateQueries: () => void;
|
||||
};
|
||||
|
||||
export type UseFeatureOptInBannerResult = {
|
||||
shouldShow: boolean;
|
||||
isLoading: boolean;
|
||||
featureConfig: OptInFeatureConfig | null;
|
||||
canOptIn: boolean;
|
||||
blockingReason: string | null;
|
||||
userRoleContext: UserRoleContext | null;
|
||||
isDialogOpen: boolean;
|
||||
openDialog: () => void;
|
||||
closeDialog: () => void;
|
||||
dismiss: () => void;
|
||||
markOptedIn: () => void;
|
||||
mutations: FeatureOptInMutations;
|
||||
};
|
||||
|
||||
function getFeaturesMap(storageKey: string): FeaturesMap {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (!stored) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
const result = featuresMapSchema.safeParse(parsed);
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function setFeatureInMap(storageKey: string, featureId: string): void {
|
||||
const current = getFeaturesMap(storageKey);
|
||||
current[featureId] = true;
|
||||
localStorage.setItem(storageKey, JSON.stringify(current));
|
||||
}
|
||||
|
||||
function isFeatureInMap(storageKey: string, featureId: string): boolean {
|
||||
const features = getFeaturesMap(storageKey);
|
||||
return features[featureId] === true;
|
||||
}
|
||||
|
||||
export function useFeatureOptInBanner(featureId: string): UseFeatureOptInBannerResult {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isDismissed, setIsDismissed] = useState(() => isFeatureInMap(DISMISSED_STORAGE_KEY, featureId));
|
||||
const [isOptedIn, setIsOptedIn] = useState(() => isFeatureInMap(OPTED_IN_STORAGE_KEY, featureId));
|
||||
|
||||
const featureConfig = useMemo(() => getOptInFeatureConfig(featureId) ?? null, [featureId]);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const eligibilityQuery = trpc.viewer.featureOptIn.checkFeatureOptInEligibility.useQuery(
|
||||
{ featureId },
|
||||
{
|
||||
enabled: !isDismissed && !isOptedIn && featureConfig !== null,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
}
|
||||
);
|
||||
|
||||
const setUserStateMutation = trpc.viewer.featureOptIn.setUserState.useMutation();
|
||||
const setTeamStateMutation = trpc.viewer.featureOptIn.setTeamState.useMutation();
|
||||
const setOrganizationStateMutation = trpc.viewer.featureOptIn.setOrganizationState.useMutation();
|
||||
const setUserAutoOptInMutation = trpc.viewer.featureOptIn.setUserAutoOptIn.useMutation();
|
||||
const setTeamAutoOptInMutation = trpc.viewer.featureOptIn.setTeamAutoOptIn.useMutation();
|
||||
const setOrganizationAutoOptInMutation = trpc.viewer.featureOptIn.setOrganizationAutoOptIn.useMutation();
|
||||
|
||||
const mutations: FeatureOptInMutations = useMemo(
|
||||
() => ({
|
||||
setUserState: (params) => setUserStateMutation.mutateAsync(params),
|
||||
setTeamState: (params) => setTeamStateMutation.mutateAsync(params),
|
||||
setOrganizationState: (params) => setOrganizationStateMutation.mutateAsync(params),
|
||||
setUserAutoOptIn: (params) => setUserAutoOptInMutation.mutateAsync(params),
|
||||
setTeamAutoOptIn: (params) => setTeamAutoOptInMutation.mutateAsync(params),
|
||||
setOrganizationAutoOptIn: (params) => setOrganizationAutoOptInMutation.mutateAsync(params),
|
||||
invalidateQueries: () => {
|
||||
utils.viewer.featureOptIn.checkFeatureOptInEligibility.invalidate();
|
||||
utils.viewer.featureOptIn.listForUser.invalidate();
|
||||
},
|
||||
}),
|
||||
[
|
||||
setUserStateMutation,
|
||||
setTeamStateMutation,
|
||||
setOrganizationStateMutation,
|
||||
setUserAutoOptInMutation,
|
||||
setTeamAutoOptInMutation,
|
||||
setOrganizationAutoOptInMutation,
|
||||
utils,
|
||||
]
|
||||
);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setFeatureInMap(DISMISSED_STORAGE_KEY, featureId);
|
||||
setIsDismissed(true);
|
||||
}, [featureId]);
|
||||
|
||||
const markOptedIn = useCallback(() => {
|
||||
setFeatureInMap(OPTED_IN_STORAGE_KEY, featureId);
|
||||
setIsOptedIn(true);
|
||||
}, [featureId]);
|
||||
|
||||
const openDialog = useCallback(() => {
|
||||
setIsDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
setIsDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const shouldShow = useMemo(() => {
|
||||
if (isDismissed) return false;
|
||||
if (isOptedIn) return false;
|
||||
if (!featureConfig) return false;
|
||||
if (eligibilityQuery.isLoading) return false;
|
||||
if (!eligibilityQuery.data) return false;
|
||||
return eligibilityQuery.data.status === "can_opt_in";
|
||||
}, [isDismissed, isOptedIn, featureConfig, eligibilityQuery.isLoading, eligibilityQuery.data]);
|
||||
|
||||
return {
|
||||
shouldShow,
|
||||
isLoading: eligibilityQuery.isLoading,
|
||||
featureConfig,
|
||||
canOptIn: eligibilityQuery.data?.canOptIn ?? false,
|
||||
blockingReason: eligibilityQuery.data?.blockingReason ?? null,
|
||||
userRoleContext: eligibilityQuery.data?.userRoleContext ?? null,
|
||||
isDialogOpen,
|
||||
openDialog,
|
||||
closeDialog,
|
||||
dismiss,
|
||||
markOptedIn,
|
||||
mutations,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import type { NormalizedFeature, UseFeatureOptInResult } from "@calcom/features/feature-opt-in/types";
|
||||
import type { FeatureState } from "@calcom/features/flags/config";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
function useMutationCallbacks(onSuccessCallback: () => void): { onSuccess: () => void; onError: () => void } {
|
||||
const { t } = useLocale();
|
||||
@@ -56,7 +55,10 @@ export function useOrganizationFeatureOptIn(): UseFeatureOptInResult {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const invalidateFeatures = useCallback(() => utils.viewer.featureOptIn.listForOrganization.invalidate(), [utils]);
|
||||
const invalidateFeatures = useCallback(
|
||||
() => utils.viewer.featureOptIn.listForOrganization.invalidate(),
|
||||
[utils]
|
||||
);
|
||||
const invalidateFeaturesAndAutoOptIn = useCallback(() => {
|
||||
utils.viewer.featureOptIn.getOrganizationAutoOptIn.invalidate();
|
||||
utils.viewer.featureOptIn.listForOrganization.invalidate();
|
||||
@@ -65,13 +67,15 @@ export function useOrganizationFeatureOptIn(): UseFeatureOptInResult {
|
||||
const setStateMutationCallbacks = useMutationCallbacks(invalidateFeatures);
|
||||
const setAutoOptInMutationCallbacks = useMutationCallbacks(invalidateFeaturesAndAutoOptIn);
|
||||
|
||||
const setStateMutation = trpc.viewer.featureOptIn.setOrganizationState.useMutation(setStateMutationCallbacks);
|
||||
const setStateMutation =
|
||||
trpc.viewer.featureOptIn.setOrganizationState.useMutation(setStateMutationCallbacks);
|
||||
const setAutoOptInMutation = trpc.viewer.featureOptIn.setOrganizationAutoOptIn.useMutation(
|
||||
setAutoOptInMutationCallbacks
|
||||
);
|
||||
|
||||
const features = normalizeFeatures(featuresQuery.data);
|
||||
const setFeatureState = (slug: string, state: FeatureState): void => setStateMutation.mutate({ slug, state });
|
||||
const setFeatureState = (slug: string, state: FeatureState): void =>
|
||||
setStateMutation.mutate({ slug, state });
|
||||
const setAutoOptIn = (checked: boolean): void => setAutoOptInMutation.mutate({ autoOptIn: checked });
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import type { TFunction } from "i18next";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import type { NormalizedFeature, UseFeatureOptInResult } from "@calcom/features/feature-opt-in/types";
|
||||
import type { FeatureState } from "@calcom/features/flags/config";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
type FeatureBlockingState = { orgState?: FeatureState };
|
||||
type TeamFeatureData = { featureId: string; globalEnabled: boolean; teamState: FeatureState; orgState: FeatureState };
|
||||
type TeamFeatureData = {
|
||||
featureId: string;
|
||||
globalEnabled: boolean;
|
||||
teamState: FeatureState;
|
||||
orgState: FeatureState;
|
||||
};
|
||||
|
||||
function useMutationCallbacks(onSuccessCallback: () => void): { onSuccess: () => void; onError: () => void } {
|
||||
const { t } = useLocale();
|
||||
@@ -73,8 +77,14 @@ export function useTeamFeatureOptIn(teamId: number): UseFeatureOptInResult {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const featuresQuery = trpc.viewer.featureOptIn.listForTeam.useQuery({ teamId }, { refetchOnWindowFocus: false });
|
||||
const autoOptInQuery = trpc.viewer.featureOptIn.getTeamAutoOptIn.useQuery({ teamId }, { refetchOnWindowFocus: false });
|
||||
const featuresQuery = trpc.viewer.featureOptIn.listForTeam.useQuery(
|
||||
{ teamId },
|
||||
{ refetchOnWindowFocus: false }
|
||||
);
|
||||
const autoOptInQuery = trpc.viewer.featureOptIn.getTeamAutoOptIn.useQuery(
|
||||
{ teamId },
|
||||
{ refetchOnWindowFocus: false }
|
||||
);
|
||||
|
||||
const invalidateFeatures = useCallback(
|
||||
() => utils.viewer.featureOptIn.listForTeam.invalidate({ teamId }),
|
||||
@@ -89,12 +99,16 @@ export function useTeamFeatureOptIn(teamId: number): UseFeatureOptInResult {
|
||||
const setAutoOptInMutationCallbacks = useMutationCallbacks(invalidateFeaturesAndAutoOptIn);
|
||||
|
||||
const setStateMutation = trpc.viewer.featureOptIn.setTeamState.useMutation(setStateMutationCallbacks);
|
||||
const setAutoOptInMutation = trpc.viewer.featureOptIn.setTeamAutoOptIn.useMutation(setAutoOptInMutationCallbacks);
|
||||
const setAutoOptInMutation = trpc.viewer.featureOptIn.setTeamAutoOptIn.useMutation(
|
||||
setAutoOptInMutationCallbacks
|
||||
);
|
||||
|
||||
const featureBlockingState = useMemo(() => buildBlockingStateMap(featuresQuery.data), [featuresQuery.data]);
|
||||
const features = normalizeTeamFeatures(featuresQuery.data);
|
||||
const setFeatureState = (slug: string, state: FeatureState): void => setStateMutation.mutate({ teamId, slug, state });
|
||||
const setAutoOptIn = (checked: boolean): void => setAutoOptInMutation.mutate({ teamId, autoOptIn: checked });
|
||||
const setFeatureState = (slug: string, state: FeatureState): void =>
|
||||
setStateMutation.mutate({ teamId, slug, state });
|
||||
const setAutoOptIn = (checked: boolean): void =>
|
||||
setAutoOptInMutation.mutate({ teamId, autoOptIn: checked });
|
||||
const getBlockedWarning = createTeamBlockedWarningFn(featureBlockingState, t);
|
||||
const isBlockedByHigherLevel = createTeamIsBlockedByHigherLevelFn(featureBlockingState);
|
||||
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import type { EffectiveStateReason } from "@calcom/features/feature-opt-in/lib/computeEffectiveState";
|
||||
import type { NormalizedFeature, UseFeatureOptInResult } from "@calcom/features/feature-opt-in/types";
|
||||
import type { FeatureState } from "@calcom/features/flags/config";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
type UserFeatureData = {
|
||||
featureId: string;
|
||||
@@ -53,8 +51,9 @@ function createUserBlockedWarningFn(t: TFunction): (feature: NormalizedFeature)
|
||||
case "feature_any_team_disabled":
|
||||
return t("feature_blocked_by_team_warning");
|
||||
case "feature_no_explicit_enablement":
|
||||
case "feature_user_only_not_allowed":
|
||||
return t("feature_no_explicit_enablement_warning");
|
||||
case "feature_user_only_not_allowed":
|
||||
return t("feature_user_only_not_allowed_warning");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -76,8 +75,12 @@ export function useUserFeatureOptIn(): UseFeatureOptInResult {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const featuresQuery = trpc.viewer.featureOptIn.listForUser.useQuery(undefined, { refetchOnWindowFocus: false });
|
||||
const autoOptInQuery = trpc.viewer.featureOptIn.getUserAutoOptIn.useQuery(undefined, { refetchOnWindowFocus: false });
|
||||
const featuresQuery = trpc.viewer.featureOptIn.listForUser.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const autoOptInQuery = trpc.viewer.featureOptIn.getUserAutoOptIn.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const invalidateFeatures = useCallback(() => utils.viewer.featureOptIn.listForUser.invalidate(), [utils]);
|
||||
const invalidateFeaturesAndAutoOptIn = useCallback(() => {
|
||||
@@ -89,10 +92,13 @@ export function useUserFeatureOptIn(): UseFeatureOptInResult {
|
||||
const setAutoOptInMutationCallbacks = useMutationCallbacks(invalidateFeaturesAndAutoOptIn);
|
||||
|
||||
const setStateMutation = trpc.viewer.featureOptIn.setUserState.useMutation(setStateMutationCallbacks);
|
||||
const setAutoOptInMutation = trpc.viewer.featureOptIn.setUserAutoOptIn.useMutation(setAutoOptInMutationCallbacks);
|
||||
const setAutoOptInMutation = trpc.viewer.featureOptIn.setUserAutoOptIn.useMutation(
|
||||
setAutoOptInMutationCallbacks
|
||||
);
|
||||
|
||||
const features = useMemo(() => normalizeUserFeatures(featuresQuery.data), [featuresQuery.data]);
|
||||
const setFeatureState = (slug: string, state: FeatureState): void => setStateMutation.mutate({ slug, state });
|
||||
const setFeatureState = (slug: string, state: FeatureState): void =>
|
||||
setStateMutation.mutate({ slug, state });
|
||||
const setAutoOptIn = (checked: boolean): void => setAutoOptInMutation.mutate({ autoOptIn: checked });
|
||||
const getBlockedWarning = createUserBlockedWarningFn(t);
|
||||
|
||||
|
||||
@@ -134,12 +134,14 @@ export const OnboardingBrowserView = ({
|
||||
{/* Profile Header */}
|
||||
<div className="border-subtle flex flex-col gap-4 border-b p-4">
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
<Avatar
|
||||
size="lg"
|
||||
imageSrc={avatar || undefined}
|
||||
alt={name || ""}
|
||||
className="border-2 border-white"
|
||||
/>
|
||||
{avatar && (
|
||||
<Avatar
|
||||
size="lg"
|
||||
imageSrc={avatar}
|
||||
alt={name || ""}
|
||||
className="border-2 border-white"
|
||||
/>
|
||||
)}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<h2 className="text-emphasis w-full truncate text-xl font-semibold leading-tight">
|
||||
{name || t("your_name")}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { trpc } from "@calcom/trpc/react";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
|
||||
import { useOnboardingStore, type Invite } from "../store/onboarding-store";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
|
||||
type OnboardingInviteBrowserViewProps = {
|
||||
teamName?: string;
|
||||
@@ -35,8 +36,14 @@ export const OnboardingInviteBrowserView = ({
|
||||
}: OnboardingInviteBrowserViewProps) => {
|
||||
const pathname = usePathname();
|
||||
const { data: user } = trpc.viewer.me.get.useQuery();
|
||||
const { teamBrand, teamInvites, invites, teamDetails, organizationBrand, organizationDetails } =
|
||||
useOnboardingStore();
|
||||
const {
|
||||
teamBrand,
|
||||
teamInvites,
|
||||
invites,
|
||||
teamDetails,
|
||||
organizationBrand,
|
||||
organizationDetails,
|
||||
} = useOnboardingStore();
|
||||
const { t } = useLocale();
|
||||
|
||||
// Animation variants for entry and exit
|
||||
@@ -57,14 +64,19 @@ export const OnboardingInviteBrowserView = ({
|
||||
|
||||
// Use default values if not provided
|
||||
const rawInviterName = user?.name || user?.username || "Alex";
|
||||
const displayInviterName = rawInviterName.charAt(0).toUpperCase() + rawInviterName.slice(1);
|
||||
const displayInviterName =
|
||||
rawInviterName.charAt(0).toUpperCase() + rawInviterName.slice(1);
|
||||
|
||||
// Use organization or team data based on context
|
||||
const displayName = useOrganizationInvites
|
||||
? organizationDetails.name || teamName || "Deel"
|
||||
: teamName || teamDetails.name || "Deel";
|
||||
const displayBio = useOrganizationInvites ? organizationDetails.bio || "" : teamDetails.bio || "";
|
||||
const avatar = useOrganizationInvites ? organizationBrand.logo || null : teamBrand.logo || null;
|
||||
const displayBio = useOrganizationInvites
|
||||
? organizationDetails.bio || ""
|
||||
: teamDetails.bio || "";
|
||||
const avatar = useOrganizationInvites
|
||||
? organizationBrand.logo || null
|
||||
: teamBrand.logo || null;
|
||||
|
||||
// Get invites based on context - use watched invites if provided, otherwise fall back to store
|
||||
let actualInvites: Invite[] = [];
|
||||
@@ -81,7 +93,9 @@ export const OnboardingInviteBrowserView = ({
|
||||
}
|
||||
|
||||
// Filter out empty invites (where email is empty or just whitespace)
|
||||
const validInvites = actualInvites.filter((invite) => invite.email && invite.email.trim().length > 0);
|
||||
const validInvites = actualInvites.filter(
|
||||
(invite) => invite.email && invite.email.trim().length > 0
|
||||
);
|
||||
|
||||
// Create empty state items
|
||||
const emptyStateItem = {
|
||||
@@ -132,31 +146,44 @@ export const OnboardingInviteBrowserView = ({
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
ease: "backOut",
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<div className="bg-default border-subtle flex flex-col rounded-2xl border">
|
||||
<div className="relative p-1">
|
||||
{/* Banner Image */}
|
||||
{organizationBrand.banner && (
|
||||
<div className="border-subtle relative h-36 w-full overflow-hidden rounded-xl border">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={organizationBrand.banner}
|
||||
alt={displayName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{organizationBrand.banner || avatar ? (
|
||||
<div className="relative p-1">
|
||||
{/* Banner Image */}
|
||||
{organizationBrand.banner && (
|
||||
<div className="border-subtle relative h-36 w-full overflow-hidden rounded-xl border">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={organizationBrand.banner}
|
||||
alt={displayName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Organization Avatar - Overlaying the banner */}
|
||||
{organizationBrand.banner && avatar && (
|
||||
<div className="absolute -bottom-6 left-4">
|
||||
<Avatar size="lg" imageSrc={avatar} alt={displayName} className="h-12 w-12 border" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Organization Avatar - Overlaying the banner */}
|
||||
{organizationBrand.banner && avatar && (
|
||||
<div className="absolute -bottom-6 left-4">
|
||||
<Avatar
|
||||
size="lg"
|
||||
imageSrc={avatar}
|
||||
alt={displayName}
|
||||
className="h-12 w-12 border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Organization Info */}
|
||||
<div className={`flex flex-col items-start gap-1 px-4 pb-4 pt-8`}>
|
||||
<div
|
||||
className={classNames(
|
||||
`flex flex-col items-start gap-1 px-4 pb-4 pt-8`,
|
||||
!organizationBrand.banner && !avatar ? "pt-4" : "pt-8"
|
||||
)}
|
||||
>
|
||||
{!organizationBrand.banner && avatar && (
|
||||
<Avatar
|
||||
size="lg"
|
||||
@@ -181,8 +208,14 @@ export const OnboardingInviteBrowserView = ({
|
||||
key={`${item.email}-${index}`}
|
||||
className={`bg-default border-subtle flex aspect-square w-full min-w-0 flex-col items-center justify-center gap-2 overflow-hidden rounded-lg border p-4 ${
|
||||
!item.isReal ? "opacity-60" : ""
|
||||
}`}>
|
||||
<Avatar size="mdLg" imageSrc={undefined} alt={item.name} className="mt-4" />
|
||||
}`}
|
||||
>
|
||||
<Avatar
|
||||
size="mdLg"
|
||||
imageSrc={undefined}
|
||||
alt={item.name}
|
||||
className="mt-4"
|
||||
/>
|
||||
<div className="flex w-full min-w-0 flex-col items-center gap-4">
|
||||
<div className="flex w-full min-w-0 flex-col items-center">
|
||||
<p className="text-default w-full truncate text-center text-sm font-semibold leading-tight">
|
||||
|
||||
@@ -9,7 +9,12 @@ import { trpc } from "@calcom/trpc/react";
|
||||
import type { OnboardingState } from "../store/onboarding-store";
|
||||
import { useOnboardingStore } from "../store/onboarding-store";
|
||||
|
||||
export function useCreateTeam() {
|
||||
type UseCreateTeamOptions = {
|
||||
redirectBasePath?: string;
|
||||
};
|
||||
|
||||
export function useCreateTeam(options: UseCreateTeamOptions = {}) {
|
||||
const { redirectBasePath = "/onboarding/teams" } = options;
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const flags = useFlagMap();
|
||||
@@ -28,7 +33,7 @@ export function useCreateTeam() {
|
||||
|
||||
// Validate team details - if empty, redirect back to team details step
|
||||
if (!teamDetails.name || !teamDetails.name.trim() || !teamDetails.slug || !teamDetails.slug.trim()) {
|
||||
router.push("/onboarding/teams/details");
|
||||
router.push(`${redirectBasePath}/details`);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
@@ -51,7 +56,7 @@ export function useCreateTeam() {
|
||||
if (result.team) {
|
||||
// Store the teamId and redirect to invite flow after team creation
|
||||
setTeamId(result.team.id);
|
||||
router.push(`/onboarding/teams/invite?teamId=${result.team.id}`);
|
||||
router.push(`${redirectBasePath}/invite/email?teamId=${result.team.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create team:", error);
|
||||
|
||||
@@ -204,7 +204,7 @@ export const PersonalSettingsView = ({
|
||||
{/* Bio */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">{t("bio")}</Label>
|
||||
<TextArea {...form.register("bio")} className="min-h-[108px]" />
|
||||
<TextArea {...form.register("bio")} className="min-h-[108px] max-h-[150px]" />
|
||||
{form.formState.errors.bio && (
|
||||
<p className="text-error text-sm">{form.formState.errors.bio.message}</p>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { FeaturesSettings } from "@calcom/features/feature-opt-in/components/FeaturesSettings";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { useUserFeatureOptIn } from "~/feature-opt-in/hooks";
|
||||
import { useUserFeatureOptIn } from "~/feature-opt-in/hooks/useUserFeatureOptIn";
|
||||
|
||||
const FeaturesView = (): ReactElement => {
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CreateANewPlatformForm } from "~/ee/platform/components/CreateANewPlatformForm";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { WizardLayout } from "@calcom/ui/components/layout";
|
||||
import { useGetUserAttributes } from "@calcom/web/components/settings/platform/hooks/useGetUserAttributes";
|
||||
|
||||
export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
const { t } = useLocale();
|
||||
const { isPlatformUser, userOrgId } = useGetUserAttributes();
|
||||
|
||||
if (!isPlatformUser && userOrgId) {
|
||||
return (
|
||||
<div className="flex h-full min-h-screen items-center justify-center">
|
||||
<div className="max-w-lg">
|
||||
<Alert severity="warning" title={t("organization_customer_cant_create_platform")} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WizardLayout currentStep={1} maxSteps={1}>
|
||||
{children}
|
||||
</WizardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateANewPlatformForm;
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { FeaturesSettings } from "@calcom/features/feature-opt-in/components/FeaturesSettings";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { useTeamFeatureOptIn } from "~/feature-opt-in/hooks";
|
||||
import { useTeamFeatureOptIn } from "~/feature-opt-in/hooks/useTeamFeatureOptIn";
|
||||
|
||||
interface TeamFeaturesViewProps {
|
||||
teamId: number;
|
||||
|
||||
@@ -1,58 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
import { z } from "zod";
|
||||
import posthog from "posthog-js";
|
||||
import React, { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
|
||||
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { WizardLayout } from "@calcom/ui/components/layout";
|
||||
import { CreateANewTeamForm } from "@calcom/web/modules/ee/teams/components/CreateANewTeamForm";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label, TextField, TextArea } from "@calcom/ui/components/form";
|
||||
import { ImageUploader } from "@calcom/ui/components/image-uploader";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
const querySchema = z.object({
|
||||
returnTo: z.string().optional(),
|
||||
slug: z.string().optional(),
|
||||
});
|
||||
import { OnboardingCard } from "~/onboarding/components/OnboardingCard";
|
||||
import { OnboardingLayout } from "~/onboarding/components/OnboardingLayout";
|
||||
import { OnboardingBrowserView } from "~/onboarding/components/onboarding-browser-view";
|
||||
import { useCreateTeam } from "~/onboarding/hooks/useCreateTeam";
|
||||
import { useOnboardingStore } from "~/onboarding/store/onboarding-store";
|
||||
import { ValidatedTeamSlug } from "~/onboarding/teams/details/validated-team-slug";
|
||||
|
||||
const CreateNewTeamPage = () => {
|
||||
const params = useParamsWithFallback();
|
||||
const parsedQuery = querySchema.safeParse(params);
|
||||
type CreateNewTeamViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
export const CreateNewTeamView = ({ userEmail }: CreateNewTeamViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const store = useOnboardingStore();
|
||||
const { teamDetails, teamBrand, setTeamDetails, setTeamBrand, resetOnboardingPreservingPlan } = store;
|
||||
const { createTeam, isSubmitting } = useCreateTeam({ redirectBasePath: "/settings/teams/new" });
|
||||
|
||||
const isTeamBillingEnabledClient = !!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && HOSTED_CAL_FEATURES;
|
||||
const flag = isTeamBillingEnabledClient
|
||||
? {
|
||||
submitLabel: "checkout",
|
||||
}
|
||||
: {
|
||||
submitLabel: "continue",
|
||||
};
|
||||
const logoRef = useRef<HTMLInputElement>(null);
|
||||
const [teamName, setTeamName] = useState("");
|
||||
const [teamSlug, setTeamSlug] = useState("");
|
||||
const [teamBio, setTeamBio] = useState("");
|
||||
const [teamLogo, setTeamLogo] = useState<string>("");
|
||||
const [isSlugValid, setIsSlugValid] = useState(false);
|
||||
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
|
||||
|
||||
const returnToParam =
|
||||
(parsedQuery.success ? getSafeRedirectUrl(parsedQuery.data.returnTo) : "/teams") || "/teams";
|
||||
// Reset onboarding store when entering the team creation flow from settings
|
||||
useEffect(() => {
|
||||
resetOnboardingPreservingPlan();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onSuccess = (data: RouterOutputs["viewer"]["teams"]["create"]) => {
|
||||
// telemetry.event(flag.telemetryEvent);
|
||||
router.push(data.url);
|
||||
useEffect(() => {
|
||||
setTeamName(teamDetails.name);
|
||||
setTeamSlug(teamDetails.slug);
|
||||
setTeamBio(teamDetails.bio);
|
||||
setTeamLogo(teamBrand.logo || "");
|
||||
if (teamDetails.slug) {
|
||||
setIsSlugManuallyEdited(true);
|
||||
}
|
||||
}, [teamDetails, teamBrand]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSlugManuallyEdited && teamName) {
|
||||
const slugifiedName = slugify(teamName);
|
||||
setTeamSlug(slugifiedName);
|
||||
}
|
||||
}, [teamName, isSlugManuallyEdited]);
|
||||
|
||||
const handleSlugChange = (value: string) => {
|
||||
setTeamSlug(value);
|
||||
setIsSlugManuallyEdited(true);
|
||||
};
|
||||
|
||||
const handleLogoChange = (newLogo: string) => {
|
||||
if (logoRef.current) {
|
||||
logoRef.current.value = newLogo;
|
||||
}
|
||||
setTeamLogo(newLogo);
|
||||
};
|
||||
|
||||
const handleContinue = async (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
|
||||
if (!isSlugValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.capture("settings_team_details_continue_clicked", {
|
||||
has_logo: !!teamLogo,
|
||||
has_bio: !!teamBio,
|
||||
});
|
||||
|
||||
setTeamDetails({
|
||||
name: teamName,
|
||||
slug: teamSlug,
|
||||
bio: teamBio,
|
||||
});
|
||||
|
||||
setTeamBrand({
|
||||
logo: teamLogo || null,
|
||||
});
|
||||
|
||||
try {
|
||||
await createTeam();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t("something_went_wrong");
|
||||
showToast(message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
posthog.capture("settings_team_details_cancel_clicked");
|
||||
resetOnboardingPreservingPlan();
|
||||
router.push("/teams");
|
||||
};
|
||||
|
||||
return (
|
||||
<CreateANewTeamForm
|
||||
slug={parsedQuery.success ? parsedQuery.data.slug : ""}
|
||||
onCancel={() => router.push(returnToParam)}
|
||||
submitLabel={flag.submitLabel}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<WizardLayout currentStep={1} maxSteps={3}>
|
||||
{children}
|
||||
</WizardLayout>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={3}>
|
||||
{/* Left column - Main content */}
|
||||
<div className="flex h-full w-full flex-col gap-4">
|
||||
<form onSubmit={handleContinue} className="flex h-full w-full flex-col gap-4">
|
||||
<OnboardingCard
|
||||
title={t("create_your_team")}
|
||||
subtitle={t("team_onboarding_details_subtitle")}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
disabled={!isSlugValid || !teamName || !teamSlug || isSubmitting}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<div className="flex h-full w-full flex-col gap-4">
|
||||
{/* Team Profile Picture */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_logo")}</Label>
|
||||
<div className="flex flex-row items-center justify-start gap-2 rtl:justify-end">
|
||||
<div className="relative shrink-0">
|
||||
<Avatar
|
||||
size="lg"
|
||||
imageSrc={teamLogo || undefined}
|
||||
alt={teamName || "Team"}
|
||||
className="border-2 border-white"
|
||||
/>
|
||||
</div>
|
||||
<input ref={logoRef} type="hidden" name="logo" id="logo" defaultValue={teamLogo} />
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="team-logo-upload"
|
||||
buttonMsg={t("upload")}
|
||||
handleAvatarChange={handleLogoChange}
|
||||
imageSrc={teamLogo}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-subtle text-xs font-normal leading-3">{t("onboarding_logo_size_hint")}</p>
|
||||
</div>
|
||||
|
||||
{/* Team Name */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">{t("team_name")}</Label>
|
||||
<TextField
|
||||
name="name"
|
||||
data-testid="team-name-input"
|
||||
value={teamName}
|
||||
onChange={(e) => setTeamName(e.target.value)}
|
||||
placeholder="Acme Inc."
|
||||
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Team Slug */}
|
||||
<ValidatedTeamSlug
|
||||
value={teamSlug}
|
||||
onChange={handleSlugChange}
|
||||
onValidationChange={setIsSlugValid}
|
||||
/>
|
||||
|
||||
{/* Team Bio */}
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">{t("team_bio")}</Label>
|
||||
<TextArea
|
||||
value={teamBio}
|
||||
onChange={(e) => setTeamBio(e.target.value)}
|
||||
placeholder={t("team_bio_placeholder")}
|
||||
className="border-default max-h-[150px] min-h-[80px] rounded-[10px] border px-2 py-1.5 text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingBrowserView teamSlug={teamSlug} name={teamName} bio={teamBio} avatar={teamLogo || null} />
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateNewTeamPage;
|
||||
export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default CreateNewTeamView;
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { useOnboardingStore, type Invite } from "~/onboarding/store/onboarding-store";
|
||||
|
||||
/**
|
||||
* Parse a CSV line handling quoted fields that may contain commas.
|
||||
* Example: '"Name, Inc",admin' -> ['Name, Inc', 'admin']
|
||||
*/
|
||||
function parseCSVLine(line: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (char === "," && !inQuotes) {
|
||||
result.push(current.trim());
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
result.push(current.trim());
|
||||
return result;
|
||||
}
|
||||
|
||||
type CSVUploadModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const CSVUploadModal = ({ isOpen, onClose }: CSVUploadModalProps) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const store = useOnboardingStore();
|
||||
const { setTeamInvites, teamDetails, teamId } = store;
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
if (!file.name.endsWith(".csv")) {
|
||||
showToast(t("please_upload_csv_file"), "error");
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
const headers = ["email", "role"];
|
||||
const exampleRow = ["example@email.com", "MEMBER"];
|
||||
const csvContent = [headers.join(","), exampleRow.join(",")].join("\n");
|
||||
|
||||
const blob = new Blob([csvContent], { type: "text/csv" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "team_invite_template.csv";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
showToast(t("template_downloaded"), "success");
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) {
|
||||
showToast(t("please_select_file"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
const text = await selectedFile.text();
|
||||
const lines = text.split("\n").filter((line) => line.trim());
|
||||
|
||||
if (lines.length < 2) {
|
||||
showToast(t("csv_file_empty"), "error");
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = parseCSVLine(lines[0]).map((h) => h.toLowerCase());
|
||||
const emailIndex = headers.indexOf("email");
|
||||
const roleIndex = headers.indexOf("role");
|
||||
|
||||
if (emailIndex === -1) {
|
||||
showToast(t("csv_missing_email_column"), "error");
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedInvites = lines
|
||||
.slice(1)
|
||||
.map((line) => {
|
||||
const values = parseCSVLine(line);
|
||||
return {
|
||||
email: values[emailIndex]?.trim(),
|
||||
role: (roleIndex !== -1 ? values[roleIndex]?.trim().toUpperCase() : "MEMBER") as
|
||||
| "MEMBER"
|
||||
| "ADMIN",
|
||||
};
|
||||
})
|
||||
.filter((invite) => invite.email && invite.email.length > 0);
|
||||
|
||||
if (parsedInvites.length === 0) {
|
||||
showToast(t("csv_file_empty"), "error");
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const invites: Invite[] = parsedInvites.map((invite) => ({
|
||||
email: invite.email,
|
||||
team: teamDetails.name,
|
||||
role: invite.role === "ADMIN" ? "ADMIN" : "MEMBER",
|
||||
}));
|
||||
|
||||
setTeamInvites(invites);
|
||||
|
||||
showToast(t("csv_uploaded_successfully", { count: invites.length }), "success");
|
||||
onClose();
|
||||
|
||||
// Navigate to email page to display the invites - use settings path
|
||||
const teamIdParam = searchParams?.get("teamId") || teamId;
|
||||
router.push(`/settings/teams/new/invite/email?teamId=${teamIdParam}`);
|
||||
} catch (error) {
|
||||
console.error("Error uploading CSV:", error);
|
||||
showToast(t("error_uploading_csv"), "error");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent size="default" className="!p-0">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Logo className="h-10 w-auto" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative mx-auto flex items-center justify-center"
|
||||
style={{ width: 320, height: 180 }}>
|
||||
<div
|
||||
className="from-default to-muted border-subtle flex items-center justify-center rounded-2xl border bg-gradient-to-br p-8 shadow-sm"
|
||||
style={{ width: 200, height: 150 }}>
|
||||
{selectedFile ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-4 shadow-sm">
|
||||
<Icon name="file-text" className="text-emphasis" style={{ width: 32, height: 32 }} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="text-emphasis text-sm font-medium">{selectedFile.name}</p>
|
||||
<p className="text-subtle text-xs">{(selectedFile.size / 1024).toFixed(2)} KB</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-4 shadow-sm">
|
||||
<Icon
|
||||
name="upload"
|
||||
className="text-emphasis opacity-70"
|
||||
style={{ width: 32, height: 32 }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-subtle text-center text-sm">{t("upload_csv_subtitle")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-col gap-2 text-center">
|
||||
<h2 className="font-heading text-emphasis text-2xl leading-none">{t("upload_csv_file")}</h2>
|
||||
<p className="text-default text-sm leading-normal">{t("upload_csv_description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-col gap-3">
|
||||
<div className="bg-cal-muted border-subtle flex items-center gap-3 rounded-lg border p-4">
|
||||
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-2 shadow-sm">
|
||||
<Icon name="download" className="text-emphasis" style={{ width: 16, height: 16 }} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-emphasis text-sm font-medium">{t("need_template")}</p>
|
||||
<p className="text-subtle text-xs">{t("download_csv_template_description")}</p>
|
||||
</div>
|
||||
<Button color="secondary" StartIcon="download" size="sm" onClick={handleDownloadTemplate}>
|
||||
{t("download_template")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-cal-muted border-subtle flex items-center gap-3 rounded-lg border p-4">
|
||||
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-2 shadow-sm">
|
||||
<Icon name="upload" className="text-emphasis" style={{ width: 16, height: 16 }} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-emphasis text-sm font-medium">{t("upload_your_file")}</p>
|
||||
<p className="text-subtle text-xs">
|
||||
{selectedFile ? selectedFile.name : t("upload_csv_description")}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
color="secondary"
|
||||
StartIcon={selectedFile ? "file-text" : "upload"}
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}>
|
||||
{t("choose_file")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-cal-muted border-subtle mt-6 flex items-center justify-between rounded-b-2xl border-t px-8 py-6">
|
||||
<Button color="minimal" onClick={handleClose} disabled={isUploading}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleUpload}
|
||||
disabled={!selectedFile || isUploading}
|
||||
loading={isUploading}>
|
||||
{t("upload")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useEffect } from "react";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Form } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions";
|
||||
import { EmailInviteForm } from "~/onboarding/components/EmailInviteForm";
|
||||
import { OnboardingCard } from "~/onboarding/components/OnboardingCard";
|
||||
import { OnboardingLayout } from "~/onboarding/components/OnboardingLayout";
|
||||
import { RoleSelector } from "~/onboarding/components/RoleSelector";
|
||||
import { OnboardingInviteBrowserView } from "~/onboarding/components/onboarding-invite-browser-view";
|
||||
import { useCreateTeam } from "~/onboarding/hooks/useCreateTeam";
|
||||
import { useOnboardingStore, type InviteRole } from "~/onboarding/store/onboarding-store";
|
||||
|
||||
type TeamInviteEmailViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
invites: {
|
||||
email: string;
|
||||
role: InviteRole;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t, i18n } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const store = useOnboardingStore();
|
||||
const { teamInvites, setTeamInvites, teamDetails, setTeamId, teamId, resetOnboardingPreservingPlan } = store;
|
||||
const [inviteRole, setInviteRole] = React.useState<InviteRole>("MEMBER");
|
||||
const { inviteMembers, isSubmitting } = useCreateTeam({ redirectBasePath: "/settings/teams/new" });
|
||||
|
||||
// Read teamId from query params and store it (from payment callback or redirect)
|
||||
useEffect(() => {
|
||||
const teamIdParam = searchParams?.get("teamId");
|
||||
if (teamIdParam && !teamId) {
|
||||
const parsedTeamId = parseInt(teamIdParam, 10);
|
||||
if (!isNaN(parsedTeamId)) {
|
||||
setTeamId(parsedTeamId);
|
||||
}
|
||||
}
|
||||
}, [searchParams, setTeamId, teamId]);
|
||||
|
||||
const formSchema = z.object({
|
||||
invites: z.array(
|
||||
z.object({
|
||||
email: z.union([z.literal(""), z.string().email(t("invalid_email_address"))]),
|
||||
role: z.enum(["MEMBER", "ADMIN"]),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
invites:
|
||||
teamInvites.length > 0
|
||||
? teamInvites.map((inv) => ({ email: inv.email, role: inv.role }))
|
||||
: [{ email: "", role: inviteRole }],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "invites",
|
||||
});
|
||||
|
||||
const handleContinue = async (data: FormValues) => {
|
||||
const teamIdParam = searchParams?.get("teamId");
|
||||
const parsedTeamId = !teamId ? parseInt(teamIdParam || "", 10) : teamId;
|
||||
if (!parsedTeamId) {
|
||||
console.log("Team ID is missing. Please go back and create your team first.");
|
||||
showToast(
|
||||
t("team_id_missing") || "Team ID is missing. Please go back and create your team first.",
|
||||
"error"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const invitesWithTeam = data.invites.map((invite) => ({
|
||||
email: invite.email,
|
||||
role: invite.role,
|
||||
team: teamDetails.name,
|
||||
}));
|
||||
|
||||
setTeamInvites(invitesWithTeam);
|
||||
|
||||
// Filter out empty emails and invite members
|
||||
const validInvites = data.invites.filter((invite) => invite.email && invite.email.trim().length > 0);
|
||||
|
||||
if (validInvites.length > 0) {
|
||||
try {
|
||||
await inviteMembers(
|
||||
validInvites.map((invite) => ({
|
||||
email: invite.email,
|
||||
role: invite.role,
|
||||
})),
|
||||
i18n.language
|
||||
);
|
||||
// Invalidate both server-side cache and client-side tRPC cache
|
||||
await revalidateTeamsList();
|
||||
await utils.viewer.teams.list.invalidate();
|
||||
resetOnboardingPreservingPlan();
|
||||
router.replace("/teams");
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : t("something_went_wrong") || "Something went wrong";
|
||||
showToast(errorMessage, "error");
|
||||
}
|
||||
} else {
|
||||
// No invites, skip to teams page
|
||||
await revalidateTeamsList();
|
||||
await utils.viewer.teams.list.invalidate();
|
||||
resetOnboardingPreservingPlan();
|
||||
router.replace("/teams");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
setTeamInvites([]);
|
||||
await revalidateTeamsList();
|
||||
await utils.viewer.teams.list.invalidate();
|
||||
resetOnboardingPreservingPlan();
|
||||
router.replace("/teams");
|
||||
};
|
||||
|
||||
// Watch form values to pass to browser view for real-time updates
|
||||
const watchedInvites = form.watch("invites");
|
||||
|
||||
const hasValidInvites = watchedInvites.some((invite) => {
|
||||
return invite.email && invite.email.trim().length > 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={3} totalSteps={3}>
|
||||
{/* Left column - Main content */}
|
||||
<div className="flex h-full w-full flex-col gap-4">
|
||||
<Form form={form} handleSubmit={handleContinue} className="flex h-full w-full flex-col gap-4">
|
||||
<OnboardingCard
|
||||
title={t("invite")}
|
||||
subtitle={t("team_invite_subtitle")}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-end gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleSkip}
|
||||
disabled={isSubmitting}
|
||||
data-testid="skip-invite-button">
|
||||
{t("onboarding_skip_for_now")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
disabled={!hasValidInvites || isSubmitting}
|
||||
loading={isSubmitting}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="flex w-full flex-col gap-4 px-1">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<EmailInviteForm
|
||||
fields={fields}
|
||||
append={append}
|
||||
remove={remove}
|
||||
defaultRole={inviteRole}
|
||||
emailPlaceholder="rick@cal.com"
|
||||
/>
|
||||
|
||||
<RoleSelector
|
||||
value={inviteRole}
|
||||
onValueChange={(value) => {
|
||||
setInviteRole(value);
|
||||
fields.forEach((_, index) => {
|
||||
form.setValue(`invites.${index}.role`, value);
|
||||
});
|
||||
}}
|
||||
showInfoBadge
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingInviteBrowserView teamName={teamDetails.name} watchedInvites={watchedInvites} />
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import posthog from "posthog-js";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
import { InviteOptions } from "~/onboarding/components/InviteOptions";
|
||||
import { OnboardingCard } from "~/onboarding/components/OnboardingCard";
|
||||
import { OnboardingLayout } from "~/onboarding/components/OnboardingLayout";
|
||||
import { OnboardingInviteBrowserView } from "~/onboarding/components/onboarding-invite-browser-view";
|
||||
import { useCreateTeam } from "~/onboarding/hooks/useCreateTeam";
|
||||
import { useOnboardingStore } from "~/onboarding/store/onboarding-store";
|
||||
|
||||
import { CSVUploadModal } from "./csv-upload-modal";
|
||||
|
||||
type TeamInviteViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
export const TeamInviteView = ({ userEmail }: TeamInviteViewProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useLocale();
|
||||
const flags = useFlags();
|
||||
|
||||
const store = useOnboardingStore();
|
||||
const { setTeamInvites, teamDetails, setTeamId, teamId, resetOnboardingPreservingPlan } = store;
|
||||
const { isSubmitting } = useCreateTeam({ redirectBasePath: "/settings/teams/new" });
|
||||
const [isCSVModalOpen, setIsCSVModalOpen] = React.useState(false);
|
||||
|
||||
// Read teamId from query params and store it (from payment callback)
|
||||
useEffect(() => {
|
||||
const teamIdParam = searchParams?.get("teamId");
|
||||
if (teamIdParam) {
|
||||
const parsedTeamId = parseInt(teamIdParam, 10);
|
||||
if (!isNaN(parsedTeamId)) {
|
||||
setTeamId(parsedTeamId);
|
||||
}
|
||||
}
|
||||
}, [searchParams, setTeamId]);
|
||||
|
||||
const googleWorkspaceEnabled = flags["google-workspace-directory"];
|
||||
|
||||
const handleGoogleWorkspaceConnect = () => {
|
||||
// TODO: Implement Google Workspace connection
|
||||
console.log("Connect Google Workspace");
|
||||
};
|
||||
|
||||
const handleInviteViaEmail = () => {
|
||||
router.push(`/settings/teams/new/invite/email?teamId=${teamId}`);
|
||||
};
|
||||
|
||||
const handleUploadCSV = () => {
|
||||
setIsCSVModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCopyInviteLink = () => {
|
||||
// Disabled for now as per requirements
|
||||
console.log("Copy invite link - disabled");
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
posthog.capture("settings_team_invite_skip_clicked");
|
||||
setTeamInvites([]);
|
||||
resetOnboardingPreservingPlan();
|
||||
// Redirect to teams page after skipping
|
||||
router.push("/teams");
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
posthog.capture("settings_team_invite_back_clicked");
|
||||
router.push("/settings/teams/new");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={3}>
|
||||
{/* Left column - Main content */}
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<OnboardingCard
|
||||
title={t("invite")}
|
||||
subtitle={t("onboarding_invite_subtitle")}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleBack}
|
||||
disabled={isSubmitting}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleSkip}
|
||||
disabled={isSubmitting}>
|
||||
{t("onboarding_skip_for_now")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<InviteOptions
|
||||
onInviteViaEmail={handleInviteViaEmail}
|
||||
onUploadCSV={handleUploadCSV}
|
||||
onCopyInviteLink={handleCopyInviteLink}
|
||||
onConnectGoogleWorkspace={googleWorkspaceEnabled ? handleGoogleWorkspaceConnect : undefined}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</OnboardingCard>
|
||||
</div>
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingInviteBrowserView teamName={teamDetails.name} />
|
||||
</OnboardingLayout>
|
||||
|
||||
{/* CSV Upload Modal */}
|
||||
<CSVUploadModal isOpen={isCSVModalOpen} onClose={() => setIsCSVModalOpen(false)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import { getBookerBaseUrlSync } from "@calcom/features/ee/organizations/lib/getB
|
||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||
import { IS_VISUAL_REGRESSION_TESTING, ENABLE_PROFILE_SWITCHER } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useIsStandalone } from "@calcom/lib/hooks/useIsStandalone";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
@@ -43,11 +44,13 @@ export type SideBarProps = {
|
||||
|
||||
export function SideBarContainer({ bannersHeight, isPlatformUser = false }: SideBarContainerProps) {
|
||||
const { status, data } = useSession();
|
||||
const isStandalone = useIsStandalone();
|
||||
|
||||
// Make sure that Sidebar is rendered optimistically so that a refresh of pages when logged in have SideBar from the beginning.
|
||||
// This improves the experience of refresh on app store pages(when logged in) which are SSG.
|
||||
// Though when logged out, app store pages would temporarily show SideBar until session status is confirmed.
|
||||
if (status !== "loading" && status !== "authenticated") return null;
|
||||
if (isStandalone) return null;
|
||||
return <SideBar isPlatformUser={isPlatformUser} bannersHeight={bannersHeight} user={data?.user} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import { useIsStandalone } from "@calcom/lib/hooks/useIsStandalone";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
@@ -11,7 +12,8 @@ import { UserDropdown } from "./user-dropdown/UserDropdown";
|
||||
|
||||
export function TopNavContainer() {
|
||||
const { status } = useSession();
|
||||
if (status !== "authenticated") return null;
|
||||
const isStandalone = useIsStandalone();
|
||||
if (status !== "authenticated" || isStandalone) return null;
|
||||
return <TopNav />;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useOrgBranding,
|
||||
type OrganizationBranding,
|
||||
} from "@calcom/features/ee/organizations/context/provider";
|
||||
import { useIsStandalone } from "@calcom/lib/hooks/useIsStandalone";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { useHasPaidPlan } from "@calcom/web/modules/billing/hooks/useHasPaidPlan";
|
||||
|
||||
@@ -230,7 +231,8 @@ export function MobileNavigationContainer({
|
||||
isPlatformNavigation?: boolean;
|
||||
}) {
|
||||
const { status } = useSession();
|
||||
if (status !== "authenticated") return null;
|
||||
const isStandalone = useIsStandalone();
|
||||
if (status !== "authenticated" || isStandalone) return null;
|
||||
return <MobileNavigation isPlatformNavigation={isPlatformNavigation} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "6.1.1",
|
||||
"version": "6.1.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -26,16 +26,18 @@ test.describe("Can signup from a team invite", async () => {
|
||||
await page.goto("/settings/teams/new");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Create a new team
|
||||
await page.locator('input[name="name"]').fill(teamName);
|
||||
await page.locator('input[name="slug"]').fill(teamName);
|
||||
// Create a new team (new onboarding-v3 style flow)
|
||||
await page.locator('[data-testid="team-name-input"]').fill(teamName);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
// Add new member to team
|
||||
await page.click('[data-testid="new-member-button"]');
|
||||
await page.fill('input[id="inviteUser"]', testUser.email);
|
||||
// Wait for the invite email page
|
||||
await page.waitForURL(/\/settings\/teams\/new\/invite\/email.*$/i);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Add new member to team via email invite form
|
||||
await page.locator('input[type="email"]').first().fill(testUser.email);
|
||||
const submitPromise = page.waitForResponse("/api/trpc/teams/inviteMember?batch=1");
|
||||
await page.getByTestId("invite-new-member-button").click();
|
||||
await page.locator('button[type="submit"]').click();
|
||||
const response = await submitPromise;
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
|
||||
@@ -29,51 +29,30 @@ test.describe("Teams", () => {
|
||||
// Click text=Create Team
|
||||
await page.locator("text=Create a new Team").click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
// Fill input[name="name"]
|
||||
await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
|
||||
// Fill team name input (new onboarding-v3 style flow)
|
||||
await page.locator('[data-testid="team-name-input"]').fill(`${user.username}'s Team`);
|
||||
// Click text=Continue
|
||||
await page.click("[type=submit]");
|
||||
// TODO: Figure out a way to make this more reliable
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
|
||||
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i);
|
||||
await page.waitForSelector('[data-testid="pending-member-list"]');
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
|
||||
// Wait for the invite email page (new flow)
|
||||
await expect(page).toHaveURL(/\/settings\/teams\/new\/invite\/email.*$/i);
|
||||
await page.waitForLoadState("networkidle");
|
||||
});
|
||||
|
||||
await test.step("Can add members", async () => {
|
||||
await page.getByTestId("new-member-button").click();
|
||||
await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail);
|
||||
await page.getByTestId("invite-new-member-button").click();
|
||||
await expect(page.getByTestId("pending-member-item").filter({ hasText: inviteeEmail })).toBeVisible();
|
||||
|
||||
// locator.count() does not await for the expected number of elements
|
||||
// https://github.com/microsoft/playwright/issues/14278
|
||||
// using toHaveCount() is more reliable
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
|
||||
await test.step("Can add members via email invite", async () => {
|
||||
// Add member via email invite form
|
||||
await page.locator('input[type="email"]').first().fill(inviteeEmail);
|
||||
await page.click("[type=submit]");
|
||||
await page.waitForLoadState("networkidle");
|
||||
// Wait for redirect to teams page
|
||||
await expect(page).toHaveURL(/\/teams$/i);
|
||||
});
|
||||
|
||||
await test.step("Can remove members", async () => {
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
|
||||
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
|
||||
await lastRemoveMemberButton.click();
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
|
||||
|
||||
// Cleanup here since this user is created without our fixtures.
|
||||
await prisma.user.delete({ where: { email: inviteeEmail } });
|
||||
});
|
||||
|
||||
await test.step("Can finish team invitation", async () => {
|
||||
await page.getByTestId("publish-button").click();
|
||||
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/event-type$/i);
|
||||
});
|
||||
|
||||
await test.step("Can finish team creation", async () => {
|
||||
const locator = page.locator('div:has(input[value="ROUND_ROBIN"]) > button');
|
||||
await expect(locator).toBeVisible();
|
||||
await locator.click();
|
||||
await page.fill("[name=title]", "roundRobin");
|
||||
await page.getByTestId("finish-button").click();
|
||||
await test.step("Can navigate to team settings", async () => {
|
||||
// Click on the team to go to settings
|
||||
await page.locator(`text=${user.username}'s Team`).click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
});
|
||||
|
||||
@@ -82,6 +61,13 @@ test.describe("Teams", () => {
|
||||
await page.getByTestId("dialog-confirmation").click();
|
||||
await page.waitForURL("/teams");
|
||||
expect(await page.locator(`text=${user.username}'s Team`).count()).toEqual(0);
|
||||
|
||||
// Cleanup the invited user since they were created without our fixtures
|
||||
const invitedUser = await prisma.user.findUnique({ where: { email: inviteeEmail } });
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (invitedUser) {
|
||||
await prisma.user.delete({ where: { email: inviteeEmail } });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,61 +1,92 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./lib/fixtures";
|
||||
import { selectFirstAvailableTimeSlotNextMonth, submitAndWaitForResponse } from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
async function goToAppsTab(page: Page, eventTypeId?: number) {
|
||||
async function goToAppsTab(page: Page, eventTypeId?: number): Promise<void> {
|
||||
await page.goto(`event-types/${eventTypeId}?tabName=apps`);
|
||||
await expect(page.getByTestId("vertical-tab-apps").first()).toHaveAttribute("aria-current", "page");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures an app is enabled in the App table and returns a cleanup function.
|
||||
* This is needed because apps without valid keys are now disabled during seeding.
|
||||
* For E2E tests, we need to enable the app to test the UI behavior.
|
||||
* The cleanup function restores the original enabled state to avoid side-effects.
|
||||
*/
|
||||
async function ensureAppEnabled(appSlug: string): Promise<() => Promise<void>> {
|
||||
const app = await prisma.app.findUnique({
|
||||
where: { slug: appSlug },
|
||||
select: { enabled: true },
|
||||
});
|
||||
const originalEnabled = app?.enabled ?? false;
|
||||
|
||||
await prisma.app.update({
|
||||
where: { slug: appSlug },
|
||||
data: { enabled: true },
|
||||
});
|
||||
|
||||
return async () => {
|
||||
await prisma.app.update({
|
||||
where: { slug: appSlug },
|
||||
data: { enabled: originalEnabled },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: E2E test suites naturally have many test cases
|
||||
test.describe("Payment app", () => {
|
||||
test("Should be able to edit alby price, currency", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
|
||||
expect(paymentEvent).not.toBeNull();
|
||||
await prisma.credential.create({
|
||||
data: {
|
||||
type: "alby_payment",
|
||||
appId: "alby",
|
||||
userId: user.id,
|
||||
key: {
|
||||
account_id: "random",
|
||||
account_email: "random@example.com",
|
||||
webhook_endpoint_id: "ep_randomString",
|
||||
webhook_endpoint_secret: "whsec_randomString",
|
||||
account_lightning_address: "random@getalby.com",
|
||||
// Ensure alby app is enabled (it may be disabled if keys are not configured)
|
||||
const cleanupAlbyApp = await ensureAppEnabled("alby");
|
||||
try {
|
||||
await prisma.credential.create({
|
||||
data: {
|
||||
type: "alby_payment",
|
||||
appId: "alby",
|
||||
userId: user.id,
|
||||
key: {
|
||||
account_id: "random",
|
||||
account_email: "random@example.com",
|
||||
webhook_endpoint_id: "ep_randomString",
|
||||
webhook_endpoint_secret: "whsec_randomString",
|
||||
account_lightning_address: "random@getalby.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await goToAppsTab(page, paymentEvent?.id);
|
||||
await goToAppsTab(page, paymentEvent?.id);
|
||||
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
await page.getByPlaceholder("Price").click();
|
||||
await page.getByPlaceholder("Price").fill("200");
|
||||
await page.getByText("SatoshissatsCurrencyBTCPayment optionCollect payment on booking").click();
|
||||
await submitAndWaitForResponse(page, "/api/trpc/eventTypesHeavy/update?batch=1", {
|
||||
action: () => page.locator("[data-testid=update-eventtype]").click(),
|
||||
});
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
await page.getByPlaceholder("Price").click();
|
||||
await page.getByPlaceholder("Price").fill("200");
|
||||
await page.getByText("SatoshissatsCurrencyBTCPayment optionCollect payment on booking").click();
|
||||
await submitAndWaitForResponse(page, "/api/trpc/eventTypesHeavy/update?batch=1", {
|
||||
action: () => page.locator("[data-testid=update-eventtype]").click(),
|
||||
});
|
||||
|
||||
await page.goto(`${user.username}/${paymentEvent?.slug}`);
|
||||
await page.goto(`${user.username}/${paymentEvent?.slug}`);
|
||||
|
||||
// expect 200 sats to be displayed in page
|
||||
await expect(page.locator("text=200 sats").first()).toBeVisible();
|
||||
// expect 200 sats to be displayed in page
|
||||
await expect(page.locator("text=200 sats").first()).toBeVisible();
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await expect(page.locator("text=200 sats").first()).toBeVisible();
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await expect(page.locator("text=200 sats").first()).toBeVisible();
|
||||
|
||||
// go to /event-types and check if the price is 200 sats
|
||||
await page.goto(`event-types/`);
|
||||
await expect(page.locator("text=200 sats").first()).toBeVisible();
|
||||
// go to /event-types and check if the price is 200 sats
|
||||
await page.goto(`event-types/`);
|
||||
await expect(page.locator("text=200 sats").first()).toBeVisible();
|
||||
} finally {
|
||||
await cleanupAlbyApp();
|
||||
}
|
||||
});
|
||||
|
||||
test("Should be able to edit stripe price, currency", async ({ page, users }) => {
|
||||
@@ -155,26 +186,32 @@ test.describe("Payment app", () => {
|
||||
await user.apiLogin();
|
||||
const paymentEvent = user.eventTypes.find((item) => item.slug === "paid");
|
||||
expect(paymentEvent).not.toBeNull();
|
||||
await prisma.credential.create({
|
||||
data: {
|
||||
type: "alby_payment",
|
||||
appId: "alby",
|
||||
userId: user.id,
|
||||
key: {},
|
||||
},
|
||||
});
|
||||
// Ensure alby app is enabled (it may be disabled if keys are not configured)
|
||||
const cleanupAlbyApp = await ensureAppEnabled("alby");
|
||||
try {
|
||||
await prisma.credential.create({
|
||||
data: {
|
||||
type: "alby_payment",
|
||||
appId: "alby",
|
||||
userId: user.id,
|
||||
key: {},
|
||||
},
|
||||
});
|
||||
|
||||
await goToAppsTab(page, paymentEvent?.id);
|
||||
await goToAppsTab(page, paymentEvent?.id);
|
||||
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
await page.locator("#event-type-form").getByRole("switch").click();
|
||||
|
||||
// expect text "This app has not been setup yet" to be displayed
|
||||
expect(await page.locator("text=This app has not been setup yet").first()).toBeTruthy();
|
||||
// expect text "This app has not been setup yet" to be displayed
|
||||
expect(await page.locator("text=This app has not been setup yet").first()).toBeTruthy();
|
||||
|
||||
await page.getByRole("button", { name: "Setup" }).click();
|
||||
await page.getByRole("button", { name: "Setup" }).click();
|
||||
|
||||
// Expect "Connect with Alby" to be displayed
|
||||
expect(await page.locator("text=Connect with Alby").first()).toBeTruthy();
|
||||
// Expect "Connect with Alby" to be displayed
|
||||
expect(await page.locator("text=Connect with Alby").first()).toBeTruthy();
|
||||
} finally {
|
||||
await cleanupAlbyApp();
|
||||
}
|
||||
});
|
||||
|
||||
test("Should display App is not setup already for paypal", async ({ page, users }) => {
|
||||
|
||||
@@ -139,11 +139,11 @@ test.describe("Teams - NonOrg", () => {
|
||||
|
||||
const uniqueName = "test-unique-team-name";
|
||||
|
||||
// Go directly to the create team page
|
||||
// Go directly to the create team page (new onboarding-v3 style flow)
|
||||
await page.goto("/settings/teams/new");
|
||||
await page.waitForLoadState("networkidle");
|
||||
// Fill input[name="name"]
|
||||
await page.locator('input[name="name"]').fill(uniqueName);
|
||||
// Fill team name input
|
||||
await page.locator('[data-testid="team-name-input"]').fill(uniqueName);
|
||||
await page.click("[type=submit]");
|
||||
|
||||
// cleanup
|
||||
@@ -163,22 +163,22 @@ test.describe("Teams - NonOrg", () => {
|
||||
// Click text=Create Team
|
||||
await page.locator("text=Create Team").click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
// Fill input[name="name"]
|
||||
await page.locator('input[name="name"]').fill(uniqueName);
|
||||
// Click text=Continue
|
||||
// Fill team name input (new onboarding-v3 style flow)
|
||||
await page.locator('[data-testid="team-name-input"]').fill(uniqueName);
|
||||
// Click Continue
|
||||
await page.click("[type=submit]");
|
||||
// TODO: Figure out a way to make this more reliable
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i);
|
||||
// Wait for the page to fully load and the publish button to be visible
|
||||
// Wait for the invite email page
|
||||
await page.waitForURL(/\/settings\/teams\/new\/invite\/email.*$/i);
|
||||
await page.waitForLoadState("networkidle");
|
||||
const publishButton = page.locator("[data-testid=publish-button]");
|
||||
await publishButton.waitFor({ state: "visible", timeout: 10000 });
|
||||
await publishButton.click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/event-type*$/i);
|
||||
await page.locator("[data-testid=handle-later-button]").click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
// Skip the invite step
|
||||
const skipButton = page.locator("[data-testid=skip-invite-button]");
|
||||
await skipButton.waitFor({ state: "visible", timeout: 10000 });
|
||||
await skipButton.click();
|
||||
// Wait for redirect to teams page
|
||||
await page.waitForURL(/\/teams$/i);
|
||||
});
|
||||
|
||||
await test.step("Can access user and team with same slug", async () => {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -130,8 +130,9 @@
|
||||
"please_try_again_later_or_book_another_slot": "You've just tried connecting now. Please try again later in {{remaining}} minutes or book another slot from the booking page.",
|
||||
"unavailable_timeslot_title": "Unavailable timeslot",
|
||||
"booking_time_out_of_bounds_error": "The event type cannot be booked at this time. Could you try another time slot?",
|
||||
"bookings_v3_title": "Enhanced bookings",
|
||||
"bookings_v3_description": "Switch between list and calendar views with details in a slide-over.",
|
||||
"bookings_v3_title": "Try the new bookings page",
|
||||
"bookings_v3_name": "Enhanced bookings",
|
||||
"bookings_v3_description": "A redesigned booking page including a calendar view.",
|
||||
"request_body_end_time_internal_error": "Internal Error. Request body does not contain end time",
|
||||
"create_calendar_event_error": "Unable to create calendar event in organizer's calendar",
|
||||
"update_calendar_event_error": "Unable to update calendar event.",
|
||||
@@ -3865,11 +3866,11 @@
|
||||
"license_key": "License key",
|
||||
"license_selection_title": "Choose your license",
|
||||
"license_selection_description": "Select how you want to license your Cal.com deployment",
|
||||
"agplv3_license_description": "Use Cal.com under the AGPLv3 open source license",
|
||||
"agplv3_license_description": "Use Cal.com under the AGPLv3 open source license",
|
||||
"enter_license_key": "Enter license key",
|
||||
"enter_your_license_key": "Access to all enterprise features",
|
||||
"enter_existing_license": "Starting at $15/user per month",
|
||||
"enter_license_key_description": "Access to all enterprise features",
|
||||
"enter_license_key_description": "Access to all enterprise features",
|
||||
"license_key_placeholder": "Enter your license key",
|
||||
"save_license_key": "Save license key",
|
||||
"signature_token_optional": "Signature token (optional)",
|
||||
@@ -4333,6 +4334,7 @@
|
||||
"feature_blocked_by_team_warning": "This feature is disabled by your team.",
|
||||
"feature_blocked_by_org_warning": "This feature is disabled by your organization.",
|
||||
"feature_no_explicit_enablement_warning": "Turn it on for yourself, or ask your team or organization admin to enable it for everyone.",
|
||||
"feature_user_only_not_allowed_warning": "This feature needs approval from your team or organization.",
|
||||
"no_opt_in_features_available": "No opt-in features are currently available",
|
||||
"enabled_via_auto_opt_in": "Enabled via auto opt-in",
|
||||
"account_already_exists_please_login": "An account with this email already exists. Please log in to accept the invitation.",
|
||||
@@ -4340,5 +4342,20 @@
|
||||
"pbac_resource_feature_opt_in": "Feature Opt-In",
|
||||
"pbac_desc_view_feature_opt_in": "View feature opt-in settings",
|
||||
"pbac_desc_update_feature_opt_in": "Manage feature opt-in settings",
|
||||
"try_it": "Try it",
|
||||
"enable_for": "Enable for",
|
||||
"entire_organization": "Entire organization",
|
||||
"my_teams_count": "My teams ({{count}})",
|
||||
"just_for_me": "Just for me",
|
||||
"for_me": "For me",
|
||||
"auto_opt_in_future_features_org": "Automatically opt into future features for my organization",
|
||||
"auto_opt_in_future_features_teams": "Automatically opt into future features for my teams",
|
||||
"auto_opt_in_future_features_user": "Automatically opt into future features",
|
||||
"feature_enabled_successfully": "Feature enabled successfully!",
|
||||
"feature_enabled_description": "You can manage this feature in your settings.",
|
||||
"view_settings": "View Settings",
|
||||
"error_enabling_feature": "Error enabling feature. Please try again.",
|
||||
"set_organizer_as_contact_owner": "Set booking organizer as contact owner",
|
||||
"overwrite_existing_contact_owner": "Overwrite existing contact owner",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS":"↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock";
|
||||
|
||||
import updateChildrenEventTypes from "@calcom/features/ee/managed-event-types/lib/handleChildrenEventTypes";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { buildEventType } from "@calcom/lib/test/builder";
|
||||
import type { EventType, Prisma, User, WorkflowsOnEventTypes } from "@calcom/prisma/client";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import updateChildrenEventTypes from "@calcom/features/ee/managed-event-types/lib/handleChildrenEventTypes";
|
||||
import { buildEventType } from "@calcom/lib/test/builder";
|
||||
import type { EventType, User, WorkflowsOnEventTypes } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
// Mock the logger module
|
||||
vi.mock("@calcom/lib/logger", () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper to setup transaction mock that executes the callback with the prisma mock
|
||||
const setupTransactionMock = () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
prismaMock.$transaction.mockImplementation(async (callback) => {
|
||||
if (typeof callback === "function") {
|
||||
return await callback(prismaMock);
|
||||
@@ -98,7 +104,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
|
||||
it("Returns message 'Missing event type'", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
prismaMock.eventType.findFirst.mockImplementation(() => {
|
||||
return new Promise((resolve) => {
|
||||
resolve(null);
|
||||
@@ -124,8 +130,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
|
||||
describe("Happy paths", () => {
|
||||
it("Adds new users", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const {
|
||||
schedulingType,
|
||||
id,
|
||||
@@ -209,8 +213,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
});
|
||||
|
||||
it("Updates old users", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const {
|
||||
schedulingType,
|
||||
id,
|
||||
@@ -233,6 +235,14 @@ describe("handleChildrenEventTypes", () => {
|
||||
metadata: { managedEventConfig: {} },
|
||||
locations: [],
|
||||
});
|
||||
|
||||
// Mock findMany for existing records lookup (new batch update optimization)
|
||||
// @ts-expect-error - partial mock for test purposes
|
||||
prismaMock.eventType.findMany.mockResolvedValue([{ userId: 4, metadata: {} }]);
|
||||
|
||||
// @ts-expect-error - partial mock for test purposes
|
||||
prismaMock.eventType.update.mockResolvedValue({ id: 100 });
|
||||
|
||||
const result = await updateChildrenEventTypes({
|
||||
eventTypeId: 1,
|
||||
oldEventType: { children: [{ userId: 4 }], team: { name: "" } },
|
||||
@@ -301,6 +311,14 @@ describe("handleChildrenEventTypes", () => {
|
||||
metadata: { managedEventConfig: {} },
|
||||
locations: [],
|
||||
});
|
||||
|
||||
// Mock findMany for existing records lookup (new batch update optimization)
|
||||
// @ts-expect-error - partial mock for test purposes
|
||||
prismaMock.eventType.findMany.mockResolvedValue([{ userId: 4, metadata: {} }]);
|
||||
|
||||
// @ts-expect-error - partial mock for test purposes
|
||||
prismaMock.eventType.update.mockResolvedValue({ id: 101 });
|
||||
|
||||
const result = await updateChildrenEventTypes({
|
||||
eventTypeId: 1,
|
||||
oldEventType: { children: [{ userId: 4 }, { userId: 1 }], team: { name: "" } },
|
||||
@@ -324,8 +342,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
|
||||
describe("Slug conflicts", () => {
|
||||
it("Deletes existent event types for new users added", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const {
|
||||
schedulingType,
|
||||
id,
|
||||
@@ -412,8 +428,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
expect(result.deletedExistentEventTypes).toEqual([123]);
|
||||
});
|
||||
it("Deletes existent event types for old users updated", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const {
|
||||
schedulingType,
|
||||
id,
|
||||
@@ -437,6 +451,14 @@ describe("handleChildrenEventTypes", () => {
|
||||
metadata: { managedEventConfig: {} },
|
||||
locations: [],
|
||||
});
|
||||
|
||||
// Mock findMany for existing records lookup (new batch update optimization)
|
||||
// @ts-expect-error - partial mock for test purposes
|
||||
prismaMock.eventType.findMany.mockResolvedValue([{ userId: 4, metadata: {} }]);
|
||||
|
||||
// @ts-expect-error - partial mock for test purposes
|
||||
prismaMock.eventType.update.mockResolvedValue({ id: 102 });
|
||||
|
||||
prismaMock.eventType.deleteMany.mockResolvedValue([123] as unknown as Prisma.BatchPayload);
|
||||
const result = await updateChildrenEventTypes({
|
||||
eventTypeId: 1,
|
||||
@@ -535,6 +557,9 @@ describe("handleChildrenEventTypes", () => {
|
||||
};
|
||||
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
|
||||
|
||||
// Mock findMany for existing records lookup (new batch update optimization)
|
||||
prismaMock.eventType.findMany.mockResolvedValue([{ userId: 4, metadata: {} } as EventType]);
|
||||
|
||||
// Mock the event type that will be returned for existing users
|
||||
const mockUpdatedEventType = {
|
||||
id: 2,
|
||||
@@ -657,8 +682,385 @@ describe("handleChildrenEventTypes", () => {
|
||||
// Since findMany returned empty array, all workflow-eventType pairs should be created
|
||||
expect(prismaMock.workflowsOnEventTypes.createMany).toHaveBeenCalledWith({
|
||||
data: [{ eventTypeId: 2, workflowId: 11 }],
|
||||
skipDuplicates: false,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Batch execution and retry behavior", () => {
|
||||
it("processes updates in batches of 50", async () => {
|
||||
// Create 120 old users to test batching (should result in 3 batches: 50, 50, 20)
|
||||
const oldUserIds = Array.from({ length: 120 }, (_, i) => i + 1);
|
||||
const children = oldUserIds.map((id) => ({
|
||||
hidden: false,
|
||||
owner: { id, name: `User ${id}`, email: `user${id}@test.com`, eventTypeSlugs: [] },
|
||||
}));
|
||||
|
||||
mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
locations: [],
|
||||
});
|
||||
|
||||
// Mock findMany for existing records lookup
|
||||
// @ts-expect-error - partial mock for test purposes
|
||||
prismaMock.eventType.findMany.mockResolvedValue(oldUserIds.map((userId) => ({ userId, metadata: {} })));
|
||||
|
||||
// Track update calls to verify batching
|
||||
let updateCallCount = 0;
|
||||
// @ts-expect-error - partial mock for test purposes
|
||||
prismaMock.eventType.update.mockImplementation(async () => {
|
||||
updateCallCount++;
|
||||
return {
|
||||
id: updateCallCount,
|
||||
userId: updateCallCount,
|
||||
title: "Test",
|
||||
slug: "test",
|
||||
length: 30,
|
||||
hidden: false,
|
||||
position: 0,
|
||||
teamId: null,
|
||||
schedulingType: SchedulingType.MANAGED,
|
||||
scheduleId: null,
|
||||
price: 0,
|
||||
currency: "usd",
|
||||
slotInterval: null,
|
||||
metadata: {},
|
||||
successRedirectUrl: null,
|
||||
bookingFields: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
});
|
||||
|
||||
const result = await updateChildrenEventTypes({
|
||||
eventTypeId: 1,
|
||||
oldEventType: { children: oldUserIds.map((userId) => ({ userId })), team: { name: "" } },
|
||||
children,
|
||||
updatedEventType: { schedulingType: "MANAGED", slug: "something" },
|
||||
currentUserId: 1,
|
||||
prisma: prismaMock,
|
||||
profileId: null,
|
||||
updatedValues: {},
|
||||
});
|
||||
|
||||
// Verify all 120 users were processed
|
||||
expect(result.oldUserIds).toHaveLength(120);
|
||||
expect(updateCallCount).toBe(120);
|
||||
// Verify findMany was called once for bulk fetch optimization
|
||||
expect(prismaMock.eventType.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries failed updates once", async () => {
|
||||
// Reset logger mocks before test
|
||||
vi.mocked(logger.info).mockClear();
|
||||
vi.mocked(logger.error).mockClear();
|
||||
|
||||
mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
locations: [],
|
||||
});
|
||||
|
||||
prismaMock.eventType.findMany.mockResolvedValue([
|
||||
{ userId: 1, metadata: {} } as EventType,
|
||||
{ userId: 2, metadata: {} } as EventType,
|
||||
{ userId: 3, metadata: {} } as EventType,
|
||||
]);
|
||||
|
||||
// Track calls per userId to simulate failure on first attempt, success on retry
|
||||
const callCountByUser: Record<number, number> = {};
|
||||
|
||||
// @ts-expect-error - partial mock for test purposes
|
||||
prismaMock.eventType.update.mockImplementation(async (args) => {
|
||||
const userId = (args.where as { userId_parentId: { userId: number } }).userId_parentId.userId;
|
||||
callCountByUser[userId] = (callCountByUser[userId] || 0) + 1;
|
||||
|
||||
// User 2 fails on first attempt, succeeds on retry
|
||||
if (userId === 2 && callCountByUser[userId] === 1) {
|
||||
throw new Error("Simulated database error");
|
||||
}
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
userId,
|
||||
title: "Test",
|
||||
slug: "test",
|
||||
length: 30,
|
||||
hidden: false,
|
||||
position: 0,
|
||||
teamId: null,
|
||||
schedulingType: SchedulingType.MANAGED,
|
||||
scheduleId: null,
|
||||
price: 0,
|
||||
currency: "usd",
|
||||
slotInterval: null,
|
||||
metadata: {},
|
||||
successRedirectUrl: null,
|
||||
bookingFields: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as unknown as EventType;
|
||||
});
|
||||
|
||||
const result = await updateChildrenEventTypes({
|
||||
eventTypeId: 1,
|
||||
oldEventType: { children: [{ userId: 1 }, { userId: 2 }, { userId: 3 }], team: { name: "" } },
|
||||
children: [
|
||||
{ hidden: false, owner: { id: 1, name: "", email: "", eventTypeSlugs: [] } },
|
||||
{ hidden: false, owner: { id: 2, name: "", email: "", eventTypeSlugs: [] } },
|
||||
{ hidden: false, owner: { id: 3, name: "", email: "", eventTypeSlugs: [] } },
|
||||
],
|
||||
updatedEventType: { schedulingType: "MANAGED", slug: "something" },
|
||||
currentUserId: 1,
|
||||
prisma: prismaMock,
|
||||
profileId: null,
|
||||
updatedValues: {},
|
||||
});
|
||||
|
||||
// All 3 users should be in oldUserIds (successful after retry)
|
||||
expect(result.oldUserIds).toEqual([1, 2, 3]);
|
||||
// User 2 should have been called twice (initial + retry)
|
||||
expect(callCountByUser[2]).toBe(2);
|
||||
// Verify retry log was called
|
||||
expect(logger.info).toHaveBeenCalledWith("Retrying 1 failed updates...");
|
||||
});
|
||||
|
||||
it("continues processing when some updates fail permanently", async () => {
|
||||
// Reset logger mocks before test
|
||||
vi.mocked(logger.info).mockClear();
|
||||
vi.mocked(logger.error).mockClear();
|
||||
|
||||
mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
locations: [],
|
||||
});
|
||||
|
||||
// Mock findMany for existing records lookup
|
||||
prismaMock.eventType.findMany.mockResolvedValue([
|
||||
{ userId: 1, metadata: {} } as EventType,
|
||||
{ userId: 2, metadata: {} } as EventType,
|
||||
{ userId: 3, metadata: {} } as EventType,
|
||||
]);
|
||||
|
||||
// @ts-expect-error - partial mock for test purposes
|
||||
prismaMock.eventType.update.mockImplementation(async (args: Prisma.EventTypeUpdateArgs) => {
|
||||
const userId = (args.where as { userId_parentId: { userId: number } }).userId_parentId.userId;
|
||||
|
||||
// User 2 always fails (permanent failure)
|
||||
if (userId === 2) {
|
||||
throw new Error("Permanent database error");
|
||||
}
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
userId,
|
||||
title: "Test",
|
||||
slug: "test",
|
||||
length: 30,
|
||||
hidden: false,
|
||||
position: 0,
|
||||
teamId: null,
|
||||
schedulingType: SchedulingType.MANAGED,
|
||||
scheduleId: null,
|
||||
price: 0,
|
||||
currency: "usd",
|
||||
slotInterval: null,
|
||||
metadata: {},
|
||||
successRedirectUrl: null,
|
||||
bookingFields: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as unknown as EventType;
|
||||
});
|
||||
|
||||
const result = await updateChildrenEventTypes({
|
||||
eventTypeId: 1,
|
||||
oldEventType: { children: [{ userId: 1 }, { userId: 2 }, { userId: 3 }], team: { name: "" } },
|
||||
children: [
|
||||
{ hidden: false, owner: { id: 1, name: "", email: "", eventTypeSlugs: [] } },
|
||||
{ hidden: false, owner: { id: 2, name: "", email: "", eventTypeSlugs: [] } },
|
||||
{ hidden: false, owner: { id: 3, name: "", email: "", eventTypeSlugs: [] } },
|
||||
],
|
||||
updatedEventType: { schedulingType: "MANAGED", slug: "something" },
|
||||
currentUserId: 1,
|
||||
prisma: prismaMock,
|
||||
profileId: null,
|
||||
updatedValues: {},
|
||||
});
|
||||
|
||||
// oldUserIds should still contain all 3 (the function returns the input, not successful ones)
|
||||
expect(result.oldUserIds).toEqual([1, 2, 3]);
|
||||
// Verify permanent failure was logged
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
"handleChildrenEventType - Could not update managed event-type",
|
||||
{
|
||||
parentId: 1,
|
||||
userIds: [2],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("handles large scale scenarios with 100+ users efficiently", async () => {
|
||||
// Create 150 old users to test large scale handling
|
||||
const oldUserIds = Array.from({ length: 150 }, (_, i) => i + 1);
|
||||
const children = oldUserIds.map((id) => ({
|
||||
hidden: false,
|
||||
owner: { id, name: `User ${id}`, email: `user${id}@test.com`, eventTypeSlugs: [] },
|
||||
}));
|
||||
|
||||
mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
locations: [],
|
||||
});
|
||||
|
||||
// Mock findMany for existing records lookup - should be called ONCE (bulk optimization)
|
||||
// @ts-expect-error - partial mock for test purposes
|
||||
prismaMock.eventType.findMany.mockResolvedValue(oldUserIds.map((userId) => ({ userId, metadata: {} })));
|
||||
|
||||
// Track timing to ensure batching doesn't cause sequential delays
|
||||
const updateResults: number[] = [];
|
||||
// @ts-expect-error - partial mock for test purposes
|
||||
prismaMock.eventType.update.mockImplementation(async (args) => {
|
||||
const userId = (args.where as { userId_parentId: { userId: number } }).userId_parentId.userId;
|
||||
updateResults.push(userId);
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
userId,
|
||||
title: "Test",
|
||||
slug: "test",
|
||||
length: 30,
|
||||
hidden: false,
|
||||
position: 0,
|
||||
teamId: null,
|
||||
schedulingType: SchedulingType.MANAGED,
|
||||
scheduleId: null,
|
||||
price: 0,
|
||||
currency: "usd",
|
||||
slotInterval: null,
|
||||
metadata: {},
|
||||
successRedirectUrl: null,
|
||||
bookingFields: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as unknown as EventType;
|
||||
});
|
||||
|
||||
const result = await updateChildrenEventTypes({
|
||||
eventTypeId: 1,
|
||||
oldEventType: { children: oldUserIds.map((userId) => ({ userId })), team: { name: "" } },
|
||||
children,
|
||||
updatedEventType: { schedulingType: "MANAGED", slug: "something" },
|
||||
currentUserId: 1,
|
||||
prisma: prismaMock,
|
||||
profileId: null,
|
||||
updatedValues: {},
|
||||
});
|
||||
|
||||
// Verify all 150 users were processed
|
||||
expect(result.oldUserIds).toHaveLength(150);
|
||||
expect(updateResults).toHaveLength(150);
|
||||
|
||||
// Verify bulk fetch optimization: findMany should be called only ONCE
|
||||
// (not 150 individual findUnique calls as in the old implementation)
|
||||
expect(prismaMock.eventType.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prismaMock.eventType.findMany).toHaveBeenCalledWith({
|
||||
where: { parentId: 1, userId: { in: oldUserIds } },
|
||||
select: { userId: true, metadata: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CalVideoSettings propagation", () => {
|
||||
it("Creates CalVideoSettings for new children when parent has settings", async () => {
|
||||
const { schedulingType, teamId, timeZone, ...evType } = mockFindFirstEventType({
|
||||
id: 123,
|
||||
metadata: { managedEventConfig: {} },
|
||||
locations: [],
|
||||
});
|
||||
|
||||
setupTransactionMock();
|
||||
prismaMock.eventType.createManyAndReturn.mockResolvedValue([
|
||||
{ ...evType, id: 456, userId: 4, schedulingType, teamId, timeZone } as unknown as EventType,
|
||||
]);
|
||||
|
||||
await updateChildrenEventTypes({
|
||||
eventTypeId: 1,
|
||||
oldEventType: { children: [], team: { name: "" } },
|
||||
children: [{ hidden: false, owner: { id: 4, name: "", email: "", eventTypeSlugs: [] } }],
|
||||
updatedEventType: { schedulingType: "MANAGED", slug: "something" },
|
||||
currentUserId: 1,
|
||||
prisma: prismaMock,
|
||||
profileId: null,
|
||||
updatedValues: {},
|
||||
calVideoSettings: {
|
||||
enableAutomaticRecordingForOrganizer: true,
|
||||
enableAutomaticTranscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(prismaMock.calVideoSettings.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
expect.objectContaining({
|
||||
eventTypeId: 456,
|
||||
enableAutomaticRecordingForOrganizer: true,
|
||||
enableAutomaticTranscription: true,
|
||||
}),
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("Syncs/deletes CalVideoSettings for existing children based on parent settings", async () => {
|
||||
const mockExistingChildUpdate = () => {
|
||||
mockFindFirstEventType({ metadata: { managedEventConfig: {} }, locations: [] });
|
||||
// @ts-expect-error - partial mock
|
||||
prismaMock.eventType.findMany.mockResolvedValue([{ userId: 4, metadata: {} }]);
|
||||
prismaMock.eventType.update.mockResolvedValue({
|
||||
id: 789,
|
||||
userId: 4,
|
||||
schedulingType: SchedulingType.MANAGED,
|
||||
} as unknown as EventType);
|
||||
};
|
||||
|
||||
const baseParams = {
|
||||
eventTypeId: 1,
|
||||
oldEventType: { children: [{ userId: 4 }], team: { name: "" } },
|
||||
children: [{ hidden: false, owner: { id: 4, name: "", email: "", eventTypeSlugs: [] } }],
|
||||
updatedEventType: { schedulingType: "MANAGED" as const, slug: "something" },
|
||||
currentUserId: 1,
|
||||
prisma: prismaMock,
|
||||
profileId: null,
|
||||
updatedValues: {},
|
||||
};
|
||||
|
||||
// Test upsert when calVideoSettings provided
|
||||
mockExistingChildUpdate();
|
||||
await updateChildrenEventTypes({
|
||||
...baseParams,
|
||||
calVideoSettings: { enableAutomaticRecordingForOrganizer: true },
|
||||
});
|
||||
expect(prismaMock.calVideoSettings.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { eventTypeId: 789 },
|
||||
update: expect.objectContaining({ enableAutomaticRecordingForOrganizer: true }),
|
||||
create: expect.objectContaining({ eventTypeId: 789, enableAutomaticRecordingForOrganizer: true }),
|
||||
})
|
||||
);
|
||||
|
||||
// Test deleteMany when calVideoSettings is null
|
||||
vi.clearAllMocks();
|
||||
mockExistingChildUpdate();
|
||||
await updateChildrenEventTypes({ ...baseParams, calVideoSettings: null });
|
||||
expect(prismaMock.calVideoSettings.deleteMany).toHaveBeenCalledWith({
|
||||
where: { eventTypeId: { in: [789] } },
|
||||
});
|
||||
|
||||
// Test no-op when calVideoSettings is undefined
|
||||
vi.clearAllMocks();
|
||||
mockExistingChildUpdate();
|
||||
await updateChildrenEventTypes({ ...baseParams, calVideoSettings: undefined });
|
||||
expect(prismaMock.calVideoSettings.upsert).not.toHaveBeenCalled();
|
||||
expect(prismaMock.calVideoSettings.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,27 @@
|
||||
"image": "./assets/splash-icon.png",
|
||||
"imageWidth": 200
|
||||
}
|
||||
],
|
||||
[
|
||||
"@react-native-community/datetimepicker",
|
||||
{
|
||||
"android": {
|
||||
"datePicker": {
|
||||
"colorAccent": {
|
||||
"light": "#000000"
|
||||
},
|
||||
"textColorPrimary": {
|
||||
"light": "#000000"
|
||||
}
|
||||
},
|
||||
"timePicker": {
|
||||
"background": { "light": "#ffffff" },
|
||||
"numbersBackgroundColor": { "light": "#ffffff" },
|
||||
"headerBackground": { "light": "#000000" },
|
||||
"numbersSelectorColor": { "light": "#000000" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PortalHost } from "@rn-primitives/portal";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Stack } from "expo-router";
|
||||
import { Platform, StatusBar, View } from "react-native";
|
||||
import { CalComLogo } from "@/components/CalComLogo";
|
||||
import LoginScreenComponent from "@/components/LoginScreen";
|
||||
import { NetworkStatusBanner } from "@/components/NetworkStatusBanner";
|
||||
import { GlobalToast } from "@/components/ui/GlobalToast";
|
||||
@@ -11,7 +12,24 @@ import { ToastProvider } from "@/contexts/ToastContext";
|
||||
import "../global.css";
|
||||
|
||||
function RootLayoutContent() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
|
||||
// Show Cal.com logo while checking auth state to prevent login flash
|
||||
if (loading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "#FFFFFF",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<StatusBar barStyle="dark-content" backgroundColor="#ffffff" />
|
||||
<CalComLogo width={120} height={26} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const content = isAuthenticated ? (
|
||||
<Stack>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"@expo/ui": "0.2.0-canary-20251230-fc48ddc",
|
||||
"@expo/vector-icons": "15.0.3",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/datetimepicker": "8.4.4",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-native-segmented-control/segmented-control": "2.5.7",
|
||||
"@rn-primitives/alert-dialog": "1.2.0",
|
||||
@@ -548,6 +549,8 @@
|
||||
|
||||
"@react-native-async-storage/async-storage": ["@react-native-async-storage/async-storage@2.2.0", "", { "dependencies": { "merge-options": "^3.0.4" }, "peerDependencies": { "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw=="],
|
||||
|
||||
"@react-native-community/datetimepicker": ["@react-native-community/datetimepicker@8.4.4", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "expo": ">=52.0.0", "react": "*", "react-native": "*", "react-native-windows": "*" }, "optionalPeers": ["expo", "react-native-windows"] }, "sha512-bc4ZixEHxZC9/qf5gbdYvIJiLZ5CLmEsC3j+Yhe1D1KC/3QhaIfGDVdUcid0PdlSoGOSEq4VlB93AWyetEyBSQ=="],
|
||||
|
||||
"@react-native-community/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="],
|
||||
|
||||
"@react-native-segmented-control/segmented-control": ["@react-native-segmented-control/segmented-control@2.5.7", "", { "peerDependencies": { "react": ">=16.0", "react-native": ">=0.62" } }, "sha512-l84YeVX8xAU3lvOJSvV4nK/NbGhIm2gBfveYolwaoCbRp+/SLXtc6mYrQmM9ScXNwU14mnzjQTpTHWl5YPnkzQ=="],
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Pressable, Text } from "react-native";
|
||||
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
|
||||
|
||||
interface LimitsTabDatePickerProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function LimitsTabDatePicker({ value, onChange, placeholder }: LimitsTabDatePickerProps) {
|
||||
// Parse value string to Date object
|
||||
const dateValue = useMemo(() => {
|
||||
if (!value) return new Date();
|
||||
const parsed = new Date(`${value}T00:00:00`);
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||
}, [value]);
|
||||
|
||||
// Format date for display (compact, human-readable)
|
||||
const displayText = useMemo(() => {
|
||||
if (!value) return placeholder || "Select date";
|
||||
return dateValue.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}, [value, dateValue, placeholder]);
|
||||
|
||||
// Handle date selection - convert to YYYY-MM-DD format
|
||||
const handleDateChange = useCallback(
|
||||
(date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
onChange(`${year}-${month}-${day}`);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
// Open native Android date picker
|
||||
const openDatePicker = useCallback(() => {
|
||||
DateTimePickerAndroid.open({
|
||||
value: dateValue,
|
||||
mode: "date",
|
||||
onChange: (event, date) => {
|
||||
if (event.type === "set" && date) {
|
||||
handleDateChange(date);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [dateValue, handleDateChange]);
|
||||
|
||||
return (
|
||||
<Pressable onPress={openDatePicker} className="rounded-lg bg-[#F2F2F7] px-3 py-2">
|
||||
<Text className={`text-[15px] ${value ? "text-black" : "text-[#8E8E93]"}`}>
|
||||
{displayText}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
|
||||
import { Alert, Pressable, ScrollView, Switch, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
|
||||
import { useUpdateSchedule } from "@/hooks/useSchedules";
|
||||
import type { Schedule } from "@/services/calcom";
|
||||
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
|
||||
// Convert 24-hour time to 12-hour format with AM/PM
|
||||
const formatTime12Hour = (time24: string): string => {
|
||||
const [hours, minutes] = time24.split(":");
|
||||
const hour = parseInt(hours, 10);
|
||||
const min = minutes || "00";
|
||||
const period = hour >= 12 ? "PM" : "AM";
|
||||
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
||||
return `${hour12}:${min} ${period}`;
|
||||
};
|
||||
|
||||
// Parse time string to Date object (for DatePicker)
|
||||
const timeStringToDate = (timeStr: string): Date => {
|
||||
const [hours, minutes] = timeStr.split(":");
|
||||
const date = new Date();
|
||||
date.setHours(parseInt(hours, 10));
|
||||
date.setMinutes(parseInt(minutes || "0", 10));
|
||||
date.setSeconds(0);
|
||||
date.setMilliseconds(0);
|
||||
return date;
|
||||
};
|
||||
|
||||
// Convert Date to time string (HH:mm)
|
||||
const dateToTimeString = (date: Date): string => {
|
||||
const hours = date.getHours().toString().padStart(2, "0");
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||
return `${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
// Normalize time string to HH:mm format (API may return HH:mm:ss)
|
||||
const normalizeTimeString = (time: string): string => {
|
||||
return time.substring(0, 5);
|
||||
};
|
||||
|
||||
// Convert Date to date string (YYYY-MM-DD)
|
||||
const dateToDateString = (date: Date): string => {
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = date.getDate().toString().padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
// Parse date string to Date object
|
||||
const dateStringToDate = (dateStr: string): Date => {
|
||||
const [year, month, day] = dateStr.split("-").map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDateForDisplay = (date: Date): string => {
|
||||
return date.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
export interface EditAvailabilityOverrideScreenProps {
|
||||
schedule: Schedule | null;
|
||||
overrideIndex?: number;
|
||||
onSuccess: () => void;
|
||||
onSavingChange?: (isSaving: boolean) => void;
|
||||
onEditOverride?: (index: number) => void;
|
||||
transparentBackground?: boolean;
|
||||
}
|
||||
|
||||
export interface EditAvailabilityOverrideScreenHandle {
|
||||
submit: () => void;
|
||||
}
|
||||
|
||||
export const EditAvailabilityOverrideScreen = forwardRef<
|
||||
EditAvailabilityOverrideScreenHandle,
|
||||
EditAvailabilityOverrideScreenProps
|
||||
>(function EditAvailabilityOverrideScreen(
|
||||
{
|
||||
schedule,
|
||||
overrideIndex,
|
||||
onSuccess,
|
||||
onSavingChange,
|
||||
onEditOverride,
|
||||
transparentBackground = false,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
|
||||
|
||||
// Use mutation hook for cache-synchronized updates
|
||||
const { mutate: updateSchedule, isPending: isMutating } = useUpdateSchedule();
|
||||
|
||||
const isEditing = overrideIndex !== undefined;
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
const [isUnavailable, setIsUnavailable] = useState(false);
|
||||
const [startTime, setStartTime] = useState<Date>(timeStringToDate("09:00"));
|
||||
const [endTime, setEndTime] = useState<Date>(timeStringToDate("17:00"));
|
||||
|
||||
// Initialize from existing override if editing
|
||||
useEffect(() => {
|
||||
if (schedule && isEditing && schedule.overrides) {
|
||||
const override = schedule.overrides[overrideIndex];
|
||||
if (override) {
|
||||
setSelectedDate(dateStringToDate(override.date ?? ""));
|
||||
// Normalize time strings to HH:mm (API may return HH:mm:ss)
|
||||
const start = normalizeTimeString(override.startTime ?? "00:00");
|
||||
const end = normalizeTimeString(override.endTime ?? "00:00");
|
||||
setIsUnavailable(start === "00:00" && end === "00:00");
|
||||
setStartTime(timeStringToDate(start === "00:00" ? "09:00" : start));
|
||||
setEndTime(timeStringToDate(end === "00:00" ? "17:00" : end));
|
||||
}
|
||||
}
|
||||
}, [schedule, isEditing, overrideIndex]);
|
||||
|
||||
// Notify parent of saving state
|
||||
useEffect(() => {
|
||||
onSavingChange?.(isMutating);
|
||||
}, [isMutating, onSavingChange]);
|
||||
|
||||
// Open native Android date picker
|
||||
const openDatePicker = useCallback(() => {
|
||||
DateTimePickerAndroid.open({
|
||||
value: selectedDate,
|
||||
mode: "date",
|
||||
onChange: (event, date) => {
|
||||
if (event.type === "set" && date) {
|
||||
setSelectedDate(date);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [selectedDate]);
|
||||
|
||||
// Open native Android time picker for start time
|
||||
const openStartTimePicker = useCallback(() => {
|
||||
DateTimePickerAndroid.open({
|
||||
value: startTime,
|
||||
mode: "time",
|
||||
is24Hour: false,
|
||||
onChange: (event, date) => {
|
||||
if (event.type === "set" && date) {
|
||||
setStartTime(date);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [startTime]);
|
||||
|
||||
// Open native Android time picker for end time
|
||||
const openEndTimePicker = useCallback(() => {
|
||||
DateTimePickerAndroid.open({
|
||||
value: endTime,
|
||||
mode: "time",
|
||||
is24Hour: false,
|
||||
onChange: (event, date) => {
|
||||
if (event.type === "set" && date) {
|
||||
setEndTime(date);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [endTime]);
|
||||
|
||||
const saveOverrides = useCallback(
|
||||
(
|
||||
newOverrides: { date: string; startTime: string; endTime: string }[],
|
||||
successMessage: string
|
||||
) => {
|
||||
if (!schedule) return;
|
||||
|
||||
updateSchedule(
|
||||
{ id: schedule.id, updates: { overrides: newOverrides } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessAlert("Success", successMessage);
|
||||
onSuccess();
|
||||
},
|
||||
onError: () => {
|
||||
showErrorAlert("Error", "Failed to save override. Please try again.");
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
[schedule, onSuccess, updateSchedule]
|
||||
);
|
||||
|
||||
const handleDeleteOverride = useCallback(
|
||||
(indexToDelete: number) => {
|
||||
if (!schedule || !schedule.overrides) return;
|
||||
|
||||
const override = schedule.overrides[indexToDelete];
|
||||
const dateDisplay = formatDateForDisplay(dateStringToDate(override?.date ?? ""));
|
||||
|
||||
Alert.alert(
|
||||
"Delete Override",
|
||||
`Are you sure you want to delete the override for ${dateDisplay}?`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
const newOverrides = schedule.overrides
|
||||
? schedule.overrides
|
||||
.filter((_, idx) => idx !== indexToDelete)
|
||||
.map((o) => ({
|
||||
date: o.date ?? "",
|
||||
startTime: (o.startTime ?? "00:00").substring(0, 5),
|
||||
endTime: (o.endTime ?? "00:00").substring(0, 5),
|
||||
}))
|
||||
: [];
|
||||
await saveOverrides(newOverrides, "Override deleted successfully");
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
},
|
||||
[schedule, saveOverrides]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!schedule || isMutating) return;
|
||||
|
||||
const dateStr = dateToDateString(selectedDate);
|
||||
|
||||
// Validate end time is after start time (only when not marking as unavailable)
|
||||
// Compare time strings to avoid issues with Date object day components
|
||||
const startTimeStr = dateToTimeString(startTime);
|
||||
const endTimeStr = dateToTimeString(endTime);
|
||||
if (!isUnavailable && endTimeStr <= startTimeStr) {
|
||||
showErrorAlert("Error", "End time must be after start time");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the new override
|
||||
const newOverride = {
|
||||
date: dateStr,
|
||||
startTime: isUnavailable ? "00:00" : dateToTimeString(startTime),
|
||||
endTime: isUnavailable ? "00:00" : dateToTimeString(endTime),
|
||||
};
|
||||
|
||||
// Build new overrides array
|
||||
let newOverrides: { date: string; startTime: string; endTime: string }[] = [];
|
||||
|
||||
if (schedule.overrides && Array.isArray(schedule.overrides)) {
|
||||
newOverrides = schedule.overrides.map((o) => ({
|
||||
date: o.date ?? "",
|
||||
startTime: (o.startTime ?? "00:00").substring(0, 5),
|
||||
endTime: (o.endTime ?? "00:00").substring(0, 5),
|
||||
}));
|
||||
}
|
||||
|
||||
const successMessage = isEditing
|
||||
? "Override updated successfully"
|
||||
: "Override added successfully";
|
||||
|
||||
if (isEditing && overrideIndex !== undefined) {
|
||||
// Check if date was changed to one that already exists (exclude current override)
|
||||
const existingIndex = newOverrides.findIndex(
|
||||
(o, idx) => o.date === dateStr && idx !== overrideIndex
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
Alert.alert(
|
||||
"Date Already Exists",
|
||||
"An override for this date already exists. Do you want to replace it?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Replace",
|
||||
onPress: () => {
|
||||
// Remove the conflicting override and update the current one
|
||||
const filteredOverrides = newOverrides.filter((_, idx) => idx !== existingIndex);
|
||||
// Adjust index if the removed item was before our edit index
|
||||
const adjustedIndex =
|
||||
existingIndex < overrideIndex ? overrideIndex - 1 : overrideIndex;
|
||||
filteredOverrides[adjustedIndex] = newOverride;
|
||||
saveOverrides(filteredOverrides, "Override updated successfully");
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Update existing override
|
||||
newOverrides[overrideIndex] = newOverride;
|
||||
} else {
|
||||
// Check if date already exists
|
||||
const existingIndex = newOverrides.findIndex((o) => o.date === dateStr);
|
||||
if (existingIndex >= 0) {
|
||||
Alert.alert(
|
||||
"Date Already Exists",
|
||||
"An override for this date already exists. Do you want to replace it?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Replace",
|
||||
onPress: () => {
|
||||
newOverrides[existingIndex] = newOverride;
|
||||
saveOverrides(newOverrides, "Override replaced successfully");
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Add new override
|
||||
newOverrides.push(newOverride);
|
||||
}
|
||||
|
||||
saveOverrides(newOverrides, successMessage);
|
||||
}, [
|
||||
schedule,
|
||||
selectedDate,
|
||||
isUnavailable,
|
||||
startTime,
|
||||
endTime,
|
||||
isEditing,
|
||||
overrideIndex,
|
||||
saveOverrides,
|
||||
isMutating,
|
||||
]);
|
||||
|
||||
// Expose submit to parent via ref
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
submit: handleSubmit,
|
||||
}),
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
if (!schedule) {
|
||||
return (
|
||||
<View className={`flex-1 items-center justify-center ${backgroundStyle}`}>
|
||||
<Text className="text-[#8E8E93]">No schedule data</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className={`flex-1 ${backgroundStyle}`}
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
showsVerticalScrollIndicator={!transparentBackground}
|
||||
>
|
||||
{/* Date Picker */}
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium text-[#8E8E93]">Date</Text>
|
||||
<Pressable
|
||||
onPress={openDatePicker}
|
||||
className={`mb-4 rounded-xl px-4 py-3.5 ${
|
||||
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
|
||||
}`}
|
||||
>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center">
|
||||
<View className="mr-3 h-9 w-9 items-center justify-center rounded-lg bg-[#007AFF]/10">
|
||||
<Ionicons name="calendar" size={20} color="#007AFF" />
|
||||
</View>
|
||||
<Text className="text-[17px] text-black">{formatDateForDisplay(selectedDate)}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Unavailable Toggle */}
|
||||
<View
|
||||
className={`mb-4 flex-row items-center justify-between rounded-xl px-4 py-3.5 ${
|
||||
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
|
||||
}`}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<View className="mr-3 h-9 w-9 items-center justify-center rounded-lg bg-[#FF3B30]/10">
|
||||
<Ionicons name="moon-outline" size={20} color="#FF3B30" />
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-[17px] text-black">Mark as unavailable</Text>
|
||||
<Text className="text-[13px] text-[#8E8E93]">Block this entire day</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Switch
|
||||
value={isUnavailable}
|
||||
onValueChange={setIsUnavailable}
|
||||
trackColor={{ false: "#E5E5EA", true: "#000000" }}
|
||||
thumbColor="#fff"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Time Range (only when available) */}
|
||||
{!isUnavailable && (
|
||||
<>
|
||||
<Text className="mb-2 px-1 text-[13px] font-medium text-[#8E8E93]">Available Hours</Text>
|
||||
<View
|
||||
className={`mb-4 rounded-xl px-4 py-3.5 ${
|
||||
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
|
||||
}`}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<Pressable onPress={openStartTimePicker} className="flex-1">
|
||||
<Text className="mb-1 text-[13px] text-[#8E8E93]">Start Time</Text>
|
||||
<Text className="text-[17px] text-black">
|
||||
{formatTime12Hour(dateToTimeString(startTime))}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Text className="mx-4 text-[17px] text-[#8E8E93]">–</Text>
|
||||
|
||||
<Pressable onPress={openEndTimePicker} className="flex-1">
|
||||
<Text className="mb-1 text-[13px] text-[#8E8E93]">End Time</Text>
|
||||
<Text className="text-[17px] text-black">
|
||||
{formatTime12Hour(dateToTimeString(endTime))}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Existing Overrides (if any) */}
|
||||
{!isEditing && schedule.overrides && schedule.overrides.length > 0 && (
|
||||
<>
|
||||
<Text className="mb-2 mt-4 px-1 text-[13px] font-medium text-[#8E8E93]">
|
||||
Existing Overrides
|
||||
</Text>
|
||||
<View
|
||||
className={`overflow-hidden rounded-xl ${
|
||||
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
|
||||
}`}
|
||||
>
|
||||
{schedule.overrides.map((override, index) => (
|
||||
<Pressable
|
||||
key={override.date}
|
||||
className={`flex-row items-center justify-between px-4 py-3 ${
|
||||
index > 0 ? "border-t border-[#E5E5EA]" : ""
|
||||
}`}
|
||||
onPress={() => onEditOverride?.(index)}
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[15px] text-black">
|
||||
{formatDateForDisplay(dateStringToDate(override.date ?? ""))}
|
||||
</Text>
|
||||
{normalizeTimeString(override.startTime ?? "00:00") === "00:00" &&
|
||||
normalizeTimeString(override.endTime ?? "00:00") === "00:00" ? (
|
||||
<Text className="text-[13px] text-[#FF3B30]">Unavailable</Text>
|
||||
) : (
|
||||
<Text className="text-[13px] text-[#8E8E93]">
|
||||
{formatTime12Hour(normalizeTimeString(override.startTime ?? "00:00"))} –{" "}
|
||||
{formatTime12Hour(normalizeTimeString(override.endTime ?? "00:00"))}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className="flex-row items-center">
|
||||
<Pressable
|
||||
className="rounded-full bg-[#FF3B30]/10 p-2"
|
||||
onPress={() => handleDeleteOverride(index)}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={18} color="#FF3B30" />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
className="ml-3 rounded-full bg-[#8E8E93]/10 p-2"
|
||||
onPress={() => onEditOverride?.(index)}
|
||||
>
|
||||
<Ionicons name="chevron-forward" size={18} color="#8E8E93" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
});
|
||||
|
||||
export default EditAvailabilityOverrideScreen;
|
||||
@@ -1,17 +1,16 @@
|
||||
/**
|
||||
* RescheduleScreen Component (Android)
|
||||
*
|
||||
* Android-specific implementation with modal-based date/time picker.
|
||||
* Uses custom modal dialogs with Material Design 3 inspired styling.
|
||||
* Android-specific implementation using the DateTimePickerAndroid imperative API
|
||||
* for native Material Design date/time picker dialogs.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
@@ -24,6 +23,7 @@ import { useRescheduleBooking } from "@/hooks/useBookings";
|
||||
import type { Booking } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
import { safeLogError, safeLogInfo } from "@/utils/safeLogger";
|
||||
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
|
||||
|
||||
export interface RescheduleScreenProps {
|
||||
booking: Booking | null;
|
||||
@@ -50,8 +50,6 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [selectedDateTime, setSelectedDateTime] = useState<Date>(new Date());
|
||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||
const [showTimePicker, setShowTimePicker] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
// Use React Query mutation for automatic cache invalidation
|
||||
@@ -100,6 +98,40 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
);
|
||||
}, [booking, selectedDateTime, reason, onSuccess, isSaving, rescheduleBooking]);
|
||||
|
||||
const openDatePicker = useCallback(() => {
|
||||
DateTimePickerAndroid.open({
|
||||
value: selectedDateTime,
|
||||
mode: "date",
|
||||
onChange: (event, date) => {
|
||||
if (event.type === "set" && date) {
|
||||
// Preserve the current time when changing date
|
||||
const newDate = new Date(date);
|
||||
newDate.setHours(selectedDateTime.getHours());
|
||||
newDate.setMinutes(selectedDateTime.getMinutes());
|
||||
setSelectedDateTime(newDate);
|
||||
}
|
||||
},
|
||||
minimumDate: new Date(), // Prevent selecting past dates
|
||||
});
|
||||
}, [selectedDateTime]);
|
||||
|
||||
const openTimePicker = useCallback(() => {
|
||||
DateTimePickerAndroid.open({
|
||||
value: selectedDateTime,
|
||||
mode: "time",
|
||||
is24Hour: false, // Match current 12-hour format
|
||||
onChange: (event, date) => {
|
||||
if (event.type === "set" && date) {
|
||||
// Preserve the current date when changing time
|
||||
const newDate = new Date(selectedDateTime);
|
||||
newDate.setHours(date.getHours());
|
||||
newDate.setMinutes(date.getMinutes());
|
||||
setSelectedDateTime(newDate);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [selectedDateTime]);
|
||||
|
||||
// Format date for display
|
||||
const formattedDate = selectedDateTime.toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
@@ -111,73 +143,6 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
// Format time for display (12-hour)
|
||||
const formattedTime = formatTime12Hour(selectedDateTime);
|
||||
|
||||
// Generate date options for picker (next 90 days)
|
||||
const dateOptions = React.useMemo(() => {
|
||||
const options: { label: string; sublabel: string; value: Date }[] = [];
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 90; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(today.getDate() + i);
|
||||
const isToday = i === 0;
|
||||
const isTomorrow = i === 1;
|
||||
options.push({
|
||||
label: isToday
|
||||
? "Today"
|
||||
: isTomorrow
|
||||
? "Tomorrow"
|
||||
: date.toLocaleDateString(undefined, {
|
||||
weekday: "long",
|
||||
}),
|
||||
sublabel: date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}),
|
||||
value: date,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, []);
|
||||
|
||||
// Extract booking times for stable dependency references
|
||||
const bookingStartTime = booking?.startTime;
|
||||
const bookingEndTime = booking?.endTime;
|
||||
|
||||
// Calculate booking duration in minutes
|
||||
const bookingDurationMinutes = React.useMemo(() => {
|
||||
if (!bookingStartTime || !bookingEndTime) return 30; // Default to 30 min
|
||||
const start = new Date(bookingStartTime);
|
||||
const end = new Date(bookingEndTime);
|
||||
const durationMs = end.getTime() - start.getTime();
|
||||
const durationMin = Math.round(durationMs / (1000 * 60));
|
||||
// Ensure slot interval is reasonable (minimum 5 min, maximum 60 min)
|
||||
if (durationMin < 5) return 5;
|
||||
if (durationMin > 60) return 60;
|
||||
return durationMin;
|
||||
}, [bookingStartTime, bookingEndTime]);
|
||||
|
||||
// Generate time options based on booking duration
|
||||
const timeOptions = React.useMemo(() => {
|
||||
const options: {
|
||||
label: string;
|
||||
value: { hour: number; minute: number };
|
||||
}[] = [];
|
||||
const interval = bookingDurationMinutes;
|
||||
|
||||
for (let totalMinutes = 0; totalMinutes < 24 * 60; totalMinutes += interval) {
|
||||
const hour = Math.floor(totalMinutes / 60);
|
||||
const minute = totalMinutes % 60;
|
||||
const ampm = hour >= 12 ? "PM" : "AM";
|
||||
const displayHour = hour % 12 || 12;
|
||||
const time = `${displayHour}:${String(minute).padStart(2, "0")} ${ampm}`;
|
||||
options.push({
|
||||
label: time,
|
||||
value: { hour, minute },
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, [bookingDurationMinutes]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -195,344 +160,161 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<KeyboardAvoidingView behavior="height" className="flex-1 bg-[#F2F2F7]">
|
||||
{/* Header - only shown when not using native header */}
|
||||
{!useNativeHeader && (
|
||||
<KeyboardAvoidingView behavior="height" className="flex-1 bg-[#F2F2F7]">
|
||||
{/* Header - only shown when not using native header */}
|
||||
{!useNativeHeader && (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#fff",
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#E5E5EA",
|
||||
elevation: 2,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#fff",
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#E5E5EA",
|
||||
elevation: 2,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
minHeight: 44,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
<AppPressable
|
||||
onPress={() => router.back()}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
minHeight: 44,
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<AppPressable
|
||||
onPress={() => router.back()}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color="#000" />
|
||||
</AppPressable>
|
||||
<Ionicons name="chevron-back" size={24} color="#000" />
|
||||
</AppPressable>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: "center",
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#000",
|
||||
marginHorizontal: 10,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
Reschedule
|
||||
</Text>
|
||||
|
||||
<AppPressable
|
||||
onPress={handleSubmit}
|
||||
disabled={isSaving}
|
||||
style={{
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
opacity: isSaving ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: "center",
|
||||
fontSize: 18,
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#000",
|
||||
marginHorizontal: 10,
|
||||
color: "#007AFF",
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
Reschedule
|
||||
Save
|
||||
</Text>
|
||||
|
||||
<AppPressable
|
||||
onPress={handleSubmit}
|
||||
disabled={isSaving}
|
||||
style={{
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
opacity: isSaving ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#007AFF",
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Text>
|
||||
</AppPressable>
|
||||
</View>
|
||||
</AppPressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Booking Title Card */}
|
||||
<View className="mb-4 flex-row items-start rounded-xl bg-white p-4">
|
||||
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<Ionicons name="calendar" size={20} color="#6B7280" />
|
||||
</View>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Booking Title Card */}
|
||||
<View className="mb-4 flex-row items-start rounded-xl bg-white p-4">
|
||||
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-[#E8E8ED]">
|
||||
<Ionicons name="calendar" size={20} color="#6B7280" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[13px] font-medium text-gray-500">Rescheduling</Text>
|
||||
<Text className="mt-0.5 text-[17px] font-medium text-[#000]" numberOfLines={2}>
|
||||
{booking.title}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Form Card */}
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
{/* Date picker trigger */}
|
||||
<Pressable
|
||||
className="flex-row items-center justify-between border-b border-gray-100 px-4 py-3"
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: pressed ? "#F9F9F9" : "transparent",
|
||||
})}
|
||||
onPress={() => {
|
||||
safeLogInfo("[RescheduleScreen] Opening date picker");
|
||||
openDatePicker();
|
||||
}}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className="text-[13px] font-medium text-gray-500">Rescheduling</Text>
|
||||
<Text className="mt-0.5 text-[17px] font-medium text-[#000]" numberOfLines={2}>
|
||||
{booking.title}
|
||||
</Text>
|
||||
<Text className="mb-1 text-[13px] font-medium text-gray-500">New Date</Text>
|
||||
<Text className="text-[17px] text-[#000]">{formattedDate}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
|
||||
</Pressable>
|
||||
|
||||
{/* Form Card */}
|
||||
<View className="mb-4 overflow-hidden rounded-xl bg-white">
|
||||
{/* Date picker trigger */}
|
||||
<Pressable
|
||||
className="flex-row items-center justify-between border-b border-gray-100 px-4 py-3"
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: pressed ? "#F9F9F9" : "transparent",
|
||||
})}
|
||||
onPress={() => {
|
||||
safeLogInfo("[RescheduleScreen] Opening date picker");
|
||||
setShowDatePicker(true);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className="mb-1 text-[13px] font-medium text-gray-500">New Date</Text>
|
||||
<Text className="text-[17px] text-[#000]">{formattedDate}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
|
||||
</Pressable>
|
||||
|
||||
{/* Time picker trigger */}
|
||||
<Pressable
|
||||
className="flex-row items-center justify-between border-b border-gray-100 px-4 py-3"
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: pressed ? "#F9F9F9" : "transparent",
|
||||
})}
|
||||
onPress={() => {
|
||||
safeLogInfo("[RescheduleScreen] Opening time picker");
|
||||
setShowTimePicker(true);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className="mb-1 text-[13px] font-medium text-gray-500">New Time</Text>
|
||||
<Text className="text-[17px] text-[#000]">{formattedTime}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
|
||||
</Pressable>
|
||||
|
||||
{/* Reason input */}
|
||||
<View className="px-4 py-3">
|
||||
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">
|
||||
Reason (optional)
|
||||
</Text>
|
||||
<TextInput
|
||||
className="min-h-[80px] text-[17px] text-[#000]"
|
||||
placeholder="Enter reason for rescheduling..."
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={reason}
|
||||
onChangeText={setReason}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* Date Picker Modal */}
|
||||
<Modal
|
||||
visible={showDatePicker}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setShowDatePicker(false)}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
{/* Time picker trigger */}
|
||||
<Pressable
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
onPress={() => setShowDatePicker(false)}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#fff",
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
maxHeight: "70%",
|
||||
paddingBottom: insets.bottom,
|
||||
className="flex-row items-center justify-between border-b border-gray-100 px-4 py-3"
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: pressed ? "#F9F9F9" : "transparent",
|
||||
})}
|
||||
onPress={() => {
|
||||
safeLogInfo("[RescheduleScreen] Opening time picker");
|
||||
openTimePicker();
|
||||
}}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{/* Header */}
|
||||
<View className="flex-row items-center justify-between border-b border-gray-200 px-4 py-3">
|
||||
<Pressable onPress={() => setShowDatePicker(false)}>
|
||||
<Text className="text-[17px] text-gray-500">Cancel</Text>
|
||||
</Pressable>
|
||||
<Text className="text-[17px] font-semibold text-[#000]">Select Date</Text>
|
||||
<Pressable onPress={() => setShowDatePicker(false)}>
|
||||
<Text className="text-[17px] font-semibold text-[#007AFF]">Done</Text>
|
||||
</Pressable>
|
||||
<View className="flex-1">
|
||||
<Text className="mb-1 text-[13px] font-medium text-gray-500">New Time</Text>
|
||||
<Text className="text-[17px] text-[#000]">{formattedTime}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
|
||||
</Pressable>
|
||||
|
||||
{/* Date List */}
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{dateOptions.map((option, index) => {
|
||||
const isSelected =
|
||||
option.value.toDateString() === selectedDateTime.toDateString();
|
||||
return (
|
||||
<Pressable
|
||||
key={option.value.toISOString()}
|
||||
className="flex-row items-center justify-between px-4 py-3.5"
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: isSelected
|
||||
? "rgba(0, 122, 255, 0.1)"
|
||||
: pressed
|
||||
? "#F5F5F5"
|
||||
: "#fff",
|
||||
borderBottomWidth: index < dateOptions.length - 1 ? 1 : 0,
|
||||
borderBottomColor: "#F2F2F7",
|
||||
})}
|
||||
onPress={() => {
|
||||
const newDate = new Date(option.value);
|
||||
newDate.setHours(selectedDateTime.getHours());
|
||||
newDate.setMinutes(selectedDateTime.getMinutes());
|
||||
setSelectedDateTime(newDate);
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<Text
|
||||
className={`text-[17px] ${
|
||||
isSelected ? "font-semibold text-[#007AFF]" : "font-medium text-[#000]"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
<Text
|
||||
className={`mt-0.5 text-[13px] ${isSelected ? "text-[#007AFF]" : "text-gray-500"}`}
|
||||
>
|
||||
{option.sublabel}
|
||||
</Text>
|
||||
</View>
|
||||
{isSelected && <Ionicons name="checkmark" size={22} color="#007AFF" />}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
{/* Reason input */}
|
||||
<View className="px-4 py-3">
|
||||
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">
|
||||
Reason (optional)
|
||||
</Text>
|
||||
<TextInput
|
||||
className="min-h-[80px] text-[17px] text-[#000]"
|
||||
placeholder="Enter reason for rescheduling..."
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={reason}
|
||||
onChangeText={setReason}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
editable={!isSaving}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* Time Picker Modal */}
|
||||
<Modal
|
||||
visible={showTimePicker}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setShowTimePicker(false)}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
onPress={() => setShowTimePicker(false)}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#fff",
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
maxHeight: "60%",
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<View className="flex-row items-center justify-between border-b border-gray-200 px-4 py-3">
|
||||
<Pressable onPress={() => setShowTimePicker(false)}>
|
||||
<Text className="text-[17px] text-gray-500">Cancel</Text>
|
||||
</Pressable>
|
||||
<Text className="text-[17px] font-semibold text-[#000]">Select Time</Text>
|
||||
<Pressable onPress={() => setShowTimePicker(false)}>
|
||||
<Text className="text-[17px] font-semibold text-[#007AFF]">Done</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Time Grid */}
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
{timeOptions.map((option) => {
|
||||
const isSelected =
|
||||
selectedDateTime.getHours() === option.value.hour &&
|
||||
selectedDateTime.getMinutes() === option.value.minute;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.label}
|
||||
style={{ width: "25%", padding: 4 }}
|
||||
onPress={() => {
|
||||
const newDate = new Date(selectedDateTime);
|
||||
newDate.setHours(option.value.hour);
|
||||
newDate.setMinutes(option.value.minute);
|
||||
setSelectedDateTime(newDate);
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className={`items-center justify-center rounded-lg py-3 ${
|
||||
isSelected ? "bg-[#007AFF]" : "bg-[#F2F2F7]"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-[13px] ${
|
||||
isSelected ? "font-semibold text-white" : "font-medium text-[#000]"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -69,6 +69,9 @@ export default defineContentScript({
|
||||
const COMPANION_URL =
|
||||
(import.meta.env.EXPO_PUBLIC_COMPANION_DEV_URL as string) || "https://companion.cal.com";
|
||||
iframe.src = COMPANION_URL;
|
||||
// Enable clipboard access for the cross-origin iframe
|
||||
// This allows the companion app to use navigator.clipboard.writeText()
|
||||
iframe.allow = "clipboard-write; clipboard-read";
|
||||
// Use explicit dimensions - Brave has issues with percentage-based sizing
|
||||
iframe.style.cssText = `
|
||||
position: absolute !important;
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"@expo/ui": "0.2.0-canary-20251230-fc48ddc",
|
||||
"@expo/vector-icons": "15.0.3",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/datetimepicker": "8.4.4",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-native-segmented-control/segmented-control": "2.5.7",
|
||||
"@rn-primitives/alert-dialog": "1.2.0",
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
*/
|
||||
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
import { showErrorAlert } from "./alerts";
|
||||
|
||||
/**
|
||||
@@ -18,6 +20,41 @@ export interface BrowserOptions {
|
||||
controlsColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends ?standalone=true to app.cal.com URLs on iOS.
|
||||
* This hides navigation elements when pages are opened in the in-app browser,
|
||||
* which is required for Apple App Store compliance.
|
||||
*
|
||||
* @param url - The URL to process
|
||||
* @returns The URL with standalone=true appended if it's an app.cal.com URL on iOS
|
||||
*/
|
||||
const appendStandaloneParam = (url: string): string => {
|
||||
// Only apply to iOS
|
||||
if (Platform.OS !== "ios") {
|
||||
return url;
|
||||
}
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
// Only apply to app.cal.com URLs
|
||||
if (urlObj.hostname !== "app.cal.com") {
|
||||
return url;
|
||||
}
|
||||
|
||||
// Don't add if already present
|
||||
if (urlObj.searchParams.has("standalone")) {
|
||||
return url;
|
||||
}
|
||||
|
||||
urlObj.searchParams.set("standalone", "true");
|
||||
return urlObj.toString();
|
||||
} catch {
|
||||
// If URL parsing fails, return original
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Open a URL in the in-app browser with session sharing enabled.
|
||||
*
|
||||
@@ -25,6 +62,9 @@ export interface BrowserOptions {
|
||||
* and Safari (iOS) or Chrome (Android). This means users who authenticate
|
||||
* via OAuth will remain logged in when opening Cal.com links.
|
||||
*
|
||||
* On iOS, app.cal.com URLs automatically get ?standalone=true appended
|
||||
* to hide navigation elements for Apple App Store compliance.
|
||||
*
|
||||
* @param url - The URL to open
|
||||
* @param fallbackMessage - Optional message to show in error alert (defaults to "link")
|
||||
* @param options - Optional browser customization options
|
||||
@@ -47,6 +87,9 @@ export const openInAppBrowser = async (
|
||||
options?: BrowserOptions
|
||||
): Promise<void> => {
|
||||
try {
|
||||
// Append standalone=true for app.cal.com URLs on iOS
|
||||
const processedUrl = appendStandaloneParam(url);
|
||||
|
||||
// Configure browser options
|
||||
// Session sharing happens automatically when using Safari View Controller (iOS)
|
||||
// or Chrome Custom Tabs (Android) - no special configuration needed
|
||||
@@ -55,7 +98,7 @@ export const openInAppBrowser = async (
|
||||
...(options?.controlsColor && { controlsColor: options.controlsColor }),
|
||||
};
|
||||
|
||||
await WebBrowser.openBrowserAsync(url, browserOptions);
|
||||
await WebBrowser.openBrowserAsync(processedUrl, browserOptions);
|
||||
} catch (error) {
|
||||
console.error("Failed to open link");
|
||||
if (__DEV__) {
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
*/
|
||||
import * as Linking from "expo-linking";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import { Alert } from "react-native";
|
||||
import { Alert, Platform } from "react-native";
|
||||
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
|
||||
// Default Cal.com web URL - can be overridden for self-hosted instances
|
||||
@@ -22,6 +23,41 @@ function getCalWebUrl(): string {
|
||||
return DEFAULT_CAL_WEB_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends ?standalone=true to Cal.com URLs on iOS.
|
||||
* This hides navigation elements when pages are opened in the in-app browser,
|
||||
* which is required for Apple App Store compliance.
|
||||
*
|
||||
* @param url - The URL to process
|
||||
* @returns The URL with standalone=true appended if it's a Cal.com URL on iOS
|
||||
*/
|
||||
function appendStandaloneParam(url: string): string {
|
||||
// Only apply to iOS
|
||||
if (Platform.OS !== "ios") {
|
||||
return url;
|
||||
}
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
// Only apply to app.cal.com URLs
|
||||
if (urlObj.hostname !== "app.cal.com") {
|
||||
return url;
|
||||
}
|
||||
|
||||
// Don't add if already present
|
||||
if (urlObj.searchParams.has("standalone")) {
|
||||
return url;
|
||||
}
|
||||
|
||||
urlObj.searchParams.set("standalone", "true");
|
||||
return urlObj.toString();
|
||||
} catch {
|
||||
// If URL parsing fails, return original
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a booking detail page in the web browser.
|
||||
* This is used for actions that require the full web experience.
|
||||
@@ -30,7 +66,7 @@ function getCalWebUrl(): string {
|
||||
*/
|
||||
export async function openBookingInWeb(bookingUid: string): Promise<void> {
|
||||
const webUrl = getCalWebUrl();
|
||||
const url = `${webUrl}/booking/${bookingUid}`;
|
||||
const url = appendStandaloneParam(`${webUrl}/booking/${bookingUid}`);
|
||||
|
||||
try {
|
||||
await WebBrowser.openBrowserAsync(url, {
|
||||
@@ -86,9 +122,9 @@ export async function openReschedulePage(
|
||||
rescheduleUid?: string
|
||||
): Promise<void> {
|
||||
const webUrl = getCalWebUrl();
|
||||
const url = rescheduleUid
|
||||
? `${webUrl}/reschedule/${rescheduleUid}`
|
||||
: `${webUrl}/booking/${bookingUid}`;
|
||||
const url = appendStandaloneParam(
|
||||
rescheduleUid ? `${webUrl}/reschedule/${rescheduleUid}` : `${webUrl}/booking/${bookingUid}`
|
||||
);
|
||||
|
||||
try {
|
||||
await WebBrowser.openBrowserAsync(url, {
|
||||
@@ -112,7 +148,7 @@ export async function openReschedulePage(
|
||||
*/
|
||||
export async function openCancelBookingInWeb(bookingUid: string): Promise<void> {
|
||||
const webUrl = getCalWebUrl();
|
||||
const url = `${webUrl}/booking/${bookingUid}`;
|
||||
const url = appendStandaloneParam(`${webUrl}/booking/${bookingUid}`);
|
||||
|
||||
try {
|
||||
await WebBrowser.openBrowserAsync(url, {
|
||||
|
||||
@@ -14,10 +14,13 @@ import type {
|
||||
AttributeOptionValue,
|
||||
} from "@calcom/app-store/routing-forms/types/types";
|
||||
import type { dynamicFieldValueOperands } from "@calcom/lib/raqb/types";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { caseInsensitive } from "@calcom/lib/raqb/utils";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { AttributeType } from "@calcom/prisma/enums";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["raqbUtils"] });
|
||||
|
||||
function ensureArray(value: string | string[]) {
|
||||
return typeof value === "string" ? [value] : value;
|
||||
}
|
||||
@@ -89,7 +92,7 @@ export function getValueOfAttributeOption(
|
||||
) {
|
||||
if (attributeOption.isGroup) {
|
||||
const subOptions = attributeOption.contains.map((option) => option.value);
|
||||
console.log("A group option found. Using all its sub-options instead", safeStringify(subOptions));
|
||||
log.debug("A group option found. Using all its sub-options instead", safeStringify(subOptions));
|
||||
return subOptions;
|
||||
}
|
||||
return attributeOption.value;
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shouldEnableApp } from "./validateAppKeys";
|
||||
|
||||
describe("shouldEnableApp", () => {
|
||||
describe("Apps without key schemas", () => {
|
||||
it("should return true for apps that don't require keys", () => {
|
||||
// Use a dirName that doesn't have a schema (e.g., a non-existent app)
|
||||
const result = shouldEnableApp("non-existent-app", null);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true even if keys are null for apps without schemas", () => {
|
||||
const result = shouldEnableApp("non-existent-app", null);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true even if keys are empty object for apps without schemas", () => {
|
||||
const result = shouldEnableApp("non-existent-app", {});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Apps with key schemas", () => {
|
||||
it("should return false when keys are null", () => {
|
||||
// dailyvideo requires api_key and has scale_plan with default
|
||||
const result = shouldEnableApp("dailyvideo", null);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when keys are undefined", () => {
|
||||
const result = shouldEnableApp("dailyvideo", undefined);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when keys is an empty object", () => {
|
||||
// Empty object doesn't have required api_key field
|
||||
const result = shouldEnableApp("dailyvideo", {});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when keys is an array", () => {
|
||||
const result = shouldEnableApp("dailyvideo", [] as any);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when keys is a string", () => {
|
||||
const result = shouldEnableApp("dailyvideo", "invalid" as any);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when keys is a number", () => {
|
||||
const result = shouldEnableApp("dailyvideo", 123 as any);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when required keys are missing", () => {
|
||||
// Missing required api_key field
|
||||
const result = shouldEnableApp("dailyvideo", {
|
||||
scale_plan: "true",
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when required keys are empty strings", () => {
|
||||
// api_key is empty string, which violates .min(1)
|
||||
const result = shouldEnableApp("dailyvideo", {
|
||||
api_key: "",
|
||||
scale_plan: "false",
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when all required keys are present and valid", () => {
|
||||
const result = shouldEnableApp("dailyvideo", {
|
||||
api_key: "valid-api-key",
|
||||
scale_plan: "false",
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when required keys are present and optional fields use defaults", () => {
|
||||
// scale_plan has a default, so we can omit it
|
||||
const result = shouldEnableApp("dailyvideo", {
|
||||
api_key: "valid-api-key",
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when keys have wrong types", () => {
|
||||
// api_key should be string, not number
|
||||
const result = shouldEnableApp("dailyvideo", {
|
||||
api_key: 123 as any,
|
||||
scale_plan: "false",
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Apps with multiple required fields", () => {
|
||||
it("should return false when any required field is missing", () => {
|
||||
// vital requires mode, region, api_key, and webhook_secret
|
||||
const result = shouldEnableApp("vital", {
|
||||
mode: "sandbox",
|
||||
region: "us",
|
||||
api_key: "test-key",
|
||||
// Missing webhook_secret
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when all required fields are present", () => {
|
||||
const result = shouldEnableApp("vital", {
|
||||
mode: "sandbox",
|
||||
region: "us",
|
||||
api_key: "test-key",
|
||||
webhook_secret: "test-secret",
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when any required field is empty", () => {
|
||||
const result = shouldEnableApp("vital", {
|
||||
mode: "",
|
||||
region: "us",
|
||||
api_key: "test-key",
|
||||
webhook_secret: "test-secret",
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Apps with empty key schemas (user-configured apps like PayPal, GTM)", () => {
|
||||
// These apps have `appKeysSchema = z.object({})` - they don't need server-side keys
|
||||
// Users configure them after installation, so they should always be enabled
|
||||
|
||||
it("should return true for PayPal when keys are null", () => {
|
||||
const result = shouldEnableApp("paypal", null);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for PayPal when keys are undefined", () => {
|
||||
const result = shouldEnableApp("paypal", undefined);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for PayPal when keys are empty object", () => {
|
||||
const result = shouldEnableApp("paypal", {});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for GTM when keys are null", () => {
|
||||
const result = shouldEnableApp("gtm", null);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for GTM when keys are undefined", () => {
|
||||
const result = shouldEnableApp("gtm", undefined);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for GA4 when keys are null", () => {
|
||||
const result = shouldEnableApp("ga4", null);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { appKeysSchemas } from "@calcom/app-store/apps.keys-schemas.generated";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
/**
|
||||
* Determines if an app should be enabled based on whether it has valid keys.
|
||||
* This is used by app registration scripts to prevent enabling apps without proper configuration.
|
||||
*
|
||||
* @param dirName - The directory name of the app
|
||||
* @param keys - The keys object (can be undefined/null)
|
||||
*/
|
||||
export function shouldEnableApp(dirName: string, keys?: Prisma.JsonValue | null): boolean {
|
||||
const keySchema = appKeysSchemas[dirName as keyof typeof appKeysSchemas];
|
||||
|
||||
// If no schema, the app doesn't require keys - can be enabled
|
||||
if (!keySchema) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If keys is null/undefined, check if the schema accepts an empty object.
|
||||
if (keys === null || keys === undefined) {
|
||||
const emptyObjectResult = keySchema.safeParse({});
|
||||
return emptyObjectResult.success;
|
||||
}
|
||||
|
||||
|
||||
// Validate keys against schema
|
||||
const result = keySchema.safeParse(keys);
|
||||
return result.success;
|
||||
}
|
||||
@@ -28,6 +28,8 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
|
||||
const ignoreGuests = getAppData("ignoreGuests") ?? false;
|
||||
const skipContactCreation = getAppData("skipContactCreation") ?? false;
|
||||
const setOrganizerAsOwner = getAppData("setOrganizerAsOwner") ?? false;
|
||||
const overwriteContactOwner = getAppData("overwriteContactOwner") ?? false;
|
||||
const onBookingWriteToEventObject = getAppData("onBookingWriteToEventObject") ?? false;
|
||||
const onBookingWriteToEventObjectFields = getAppData("onBookingWriteToEventObjectFields") ?? {};
|
||||
|
||||
@@ -37,7 +39,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
returnTo={`${WEBAPP_URL}${pathname}?tabName=apps`}
|
||||
app={app}
|
||||
teamId={eventType.team?.id || undefined}
|
||||
switchOnClick={(e) => {
|
||||
switchOnClick={(e: boolean): void => {
|
||||
updateEnabled(e);
|
||||
}}
|
||||
switchChecked={enabled}
|
||||
@@ -52,7 +54,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
size="sm"
|
||||
labelOnLeading
|
||||
checked={ignoreGuests}
|
||||
onCheckedChange={(checked) => {
|
||||
onCheckedChange={(checked: boolean): void => {
|
||||
setAppData("ignoreGuests", checked);
|
||||
}}
|
||||
/>
|
||||
@@ -67,12 +69,47 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
size="sm"
|
||||
labelOnLeading
|
||||
checked={skipContactCreation}
|
||||
onCheckedChange={(checked) => {
|
||||
onCheckedChange={(checked: boolean): void => {
|
||||
setAppData("skipContactCreation", checked);
|
||||
}}
|
||||
/>
|
||||
</Section.SubSectionHeader>
|
||||
</Section.SubSection>
|
||||
<Section.SubSection>
|
||||
<Section.SubSectionHeader
|
||||
icon="user-check"
|
||||
title={t("set_organizer_as_contact_owner")}
|
||||
labelFor="set-organizer-as-owner">
|
||||
<Switch
|
||||
size="sm"
|
||||
labelOnLeading
|
||||
checked={setOrganizerAsOwner}
|
||||
onCheckedChange={(checked: boolean): void => {
|
||||
setAppData("setOrganizerAsOwner", checked);
|
||||
if (!checked) {
|
||||
setAppData("overwriteContactOwner", false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Section.SubSectionHeader>
|
||||
</Section.SubSection>
|
||||
{setOrganizerAsOwner && (
|
||||
<Section.SubSection>
|
||||
<Section.SubSectionHeader
|
||||
icon="refresh-cw"
|
||||
title={t("overwrite_existing_contact_owner")}
|
||||
labelFor="overwrite-contact-owner">
|
||||
<Switch
|
||||
size="sm"
|
||||
labelOnLeading
|
||||
checked={overwriteContactOwner}
|
||||
onCheckedChange={(checked: boolean): void => {
|
||||
setAppData("overwriteContactOwner", checked);
|
||||
}}
|
||||
/>
|
||||
</Section.SubSectionHeader>
|
||||
</Section.SubSection>
|
||||
)}
|
||||
|
||||
<Section.SubSection>
|
||||
<WriteToObjectSettings
|
||||
@@ -80,10 +117,10 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
optionLabel={t("on_booking_write_to_event_object")}
|
||||
optionEnabled={onBookingWriteToEventObject}
|
||||
writeToObjectData={onBookingWriteToEventObjectFields}
|
||||
optionSwitchOnChange={(checked) => {
|
||||
optionSwitchOnChange={(checked: boolean): void => {
|
||||
setAppData("onBookingWriteToEventObject", checked);
|
||||
}}
|
||||
updateWriteToObjectData={(data) => setAppData("onBookingWriteToEventObjectFields", data)}
|
||||
updateWriteToObjectData={(data): void => setAppData("onBookingWriteToEventObjectFields", data)}
|
||||
supportedFieldTypes={[
|
||||
CrmFieldType.TEXT,
|
||||
CrmFieldType.DATE,
|
||||
|
||||
@@ -24,7 +24,11 @@ const {
|
||||
properties: { coreApi: { getAll: ReturnType<typeof vi.fn> } };
|
||||
contacts: {
|
||||
searchApi: { doSearch: ReturnType<typeof vi.fn> };
|
||||
basicApi: { create: ReturnType<typeof vi.fn> };
|
||||
basicApi: {
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
getById: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
objects: {
|
||||
meetings: {
|
||||
@@ -58,6 +62,8 @@ const {
|
||||
},
|
||||
basicApi: {
|
||||
create: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
objects: {
|
||||
@@ -670,15 +676,26 @@ describe("HubspotCalendarService", () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("should set hubspot owner when organizer email matches", async () => {
|
||||
mockAppOptions({});
|
||||
it("should set contact owner when setOrganizerAsOwner is enabled and organizer email matches", async () => {
|
||||
mockAppOptions({ setOrganizerAsOwner: true });
|
||||
|
||||
// Mock owner lookup - return matching owner
|
||||
mockHubspotClient.crm.owners.ownersApi.getPage.mockResolvedValue({
|
||||
results: [{ id: "owner-123", email: "organizer@example.com" }],
|
||||
});
|
||||
|
||||
// Mock contact owner lookup - return no existing owner
|
||||
mockHubspotClient.crm.contacts.basicApi.getById.mockResolvedValue({
|
||||
id: "contact-1",
|
||||
properties: { hubspot_owner_id: null },
|
||||
});
|
||||
|
||||
// Mock contact update
|
||||
mockHubspotClient.crm.contacts.basicApi.update.mockResolvedValue({
|
||||
id: "contact-1",
|
||||
properties: { hubspot_owner_id: "owner-123" },
|
||||
});
|
||||
|
||||
mockHubspotClient.crm.objects.meetings.basicApi.create.mockResolvedValue({
|
||||
id: "meeting-123",
|
||||
properties: {},
|
||||
@@ -693,10 +710,106 @@ describe("HubspotCalendarService", () => {
|
||||
|
||||
await service.createEvent(event, contacts);
|
||||
|
||||
// Verify contact owner was set
|
||||
expect(mockHubspotClient.crm.contacts.basicApi.update).toHaveBeenCalledWith("contact-1", {
|
||||
properties: { hubspot_owner_id: "owner-123" },
|
||||
});
|
||||
|
||||
// Verify meeting was created with hubspot_owner_id (meeting owner is always set from organizer)
|
||||
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
|
||||
expect(createCall.properties.hubspot_owner_id).toBe("owner-123");
|
||||
});
|
||||
|
||||
it("should overwrite existing contact owner when overwriteContactOwner is true", async () => {
|
||||
mockAppOptions({ setOrganizerAsOwner: true, overwriteContactOwner: true });
|
||||
|
||||
mockHubspotClient.crm.owners.ownersApi.getPage.mockResolvedValue({
|
||||
results: [{ id: "owner-123", email: "organizer@example.com" }],
|
||||
});
|
||||
|
||||
mockHubspotClient.crm.contacts.basicApi.update.mockResolvedValue({
|
||||
id: "contact-1",
|
||||
properties: { hubspot_owner_id: "owner-123" },
|
||||
});
|
||||
|
||||
mockHubspotClient.crm.objects.meetings.basicApi.create.mockResolvedValue({
|
||||
id: "meeting-123",
|
||||
properties: {},
|
||||
});
|
||||
|
||||
mockHubspotClient.crm.associations.batchApi.create.mockResolvedValue({
|
||||
results: [],
|
||||
});
|
||||
|
||||
const event = createMockEvent();
|
||||
const contacts = [{ id: "contact-1", email: "attendee@example.com" }];
|
||||
|
||||
await service.createEvent(event, contacts);
|
||||
|
||||
expect(mockHubspotClient.crm.contacts.basicApi.update).toHaveBeenCalledWith("contact-1", {
|
||||
properties: { hubspot_owner_id: "owner-123" },
|
||||
});
|
||||
expect(mockHubspotClient.crm.contacts.basicApi.getById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not overwrite existing contact owner when overwriteContactOwner is false", async () => {
|
||||
mockAppOptions({ setOrganizerAsOwner: true, overwriteContactOwner: false });
|
||||
|
||||
mockHubspotClient.crm.owners.ownersApi.getPage.mockResolvedValue({
|
||||
results: [{ id: "owner-123", email: "organizer@example.com" }],
|
||||
});
|
||||
|
||||
mockHubspotClient.crm.contacts.basicApi.getById.mockResolvedValue({
|
||||
id: "contact-1",
|
||||
properties: { hubspot_owner_id: "existing-owner-456" },
|
||||
});
|
||||
|
||||
mockHubspotClient.crm.objects.meetings.basicApi.create.mockResolvedValue({
|
||||
id: "meeting-123",
|
||||
properties: {},
|
||||
});
|
||||
|
||||
mockHubspotClient.crm.associations.batchApi.create.mockResolvedValue({
|
||||
results: [],
|
||||
});
|
||||
|
||||
const event = createMockEvent();
|
||||
const contacts = [{ id: "contact-1", email: "attendee@example.com" }];
|
||||
|
||||
await service.createEvent(event, contacts);
|
||||
|
||||
expect(mockHubspotClient.crm.contacts.basicApi.getById).toHaveBeenCalledWith("contact-1", [
|
||||
"hubspot_owner_id",
|
||||
]);
|
||||
expect(mockHubspotClient.crm.contacts.basicApi.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not set contact owner when organizer has no matching HubSpot owner", async () => {
|
||||
mockAppOptions({ setOrganizerAsOwner: true });
|
||||
|
||||
mockHubspotClient.crm.owners.ownersApi.getPage.mockResolvedValue({ results: [] });
|
||||
|
||||
mockHubspotClient.crm.objects.meetings.basicApi.create.mockResolvedValue({
|
||||
id: "meeting-123",
|
||||
properties: {},
|
||||
});
|
||||
|
||||
mockHubspotClient.crm.associations.batchApi.create.mockResolvedValue({
|
||||
results: [],
|
||||
});
|
||||
|
||||
const event = createMockEvent();
|
||||
const contacts = [{ id: "contact-1", email: "attendee@example.com" }];
|
||||
|
||||
await service.createEvent(event, contacts);
|
||||
|
||||
expect(mockHubspotClient.crm.contacts.basicApi.getById).not.toHaveBeenCalled();
|
||||
expect(mockHubspotClient.crm.contacts.basicApi.update).not.toHaveBeenCalled();
|
||||
|
||||
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
|
||||
expect(createCall.properties.hubspot_owner_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create meeting successfully when owner lookup fails due to missing scope", async () => {
|
||||
mockAppOptions({});
|
||||
|
||||
|
||||
@@ -69,15 +69,17 @@ class HubspotCalendarService implements CRM {
|
||||
.join("<br><br>");
|
||||
|
||||
const organizerName = event.organizer.name || event.organizer.email;
|
||||
const organizerInfo = `<b>${event.organizer.language.translate("organizer")}:</b> ${organizerName} (${event.organizer.email
|
||||
})`;
|
||||
const organizerInfo = `<b>${event.organizer.language.translate("organizer")}:</b> ${organizerName} (${event.organizer.email})`;
|
||||
|
||||
return `${organizerInfo}<br><br><b>${event.organizer.language.translate("invitee_timezone")}:</b> ${event.attendees[0].timeZone
|
||||
}<br><br>${event.additionalNotes
|
||||
? `<b>${event.organizer.language.translate("share_additional_notes")}</b><br>${event.additionalNotes
|
||||
}<br><br>`
|
||||
return `${organizerInfo}<br><br><b>${event.organizer.language.translate("invitee_timezone")}:</b> ${
|
||||
event.attendees[0].timeZone
|
||||
}<br><br>${
|
||||
event.additionalNotes
|
||||
? `<b>${event.organizer.language.translate("share_additional_notes")}</b><br>${
|
||||
event.additionalNotes
|
||||
}<br><br>`
|
||||
: ""
|
||||
}
|
||||
}
|
||||
${userFieldsHtml}<br><br>
|
||||
<b>${event.organizer.language.translate("where")}:</b> ${location}<br><br>
|
||||
${plainText ? `<b>${event.organizer.language.translate("description")}</b><br>${plainText}` : ""}
|
||||
@@ -87,7 +89,7 @@ class HubspotCalendarService implements CRM {
|
||||
private async ensureFieldsExistOnMeeting(fieldsToTest: string[]) {
|
||||
const log = logger.getSubLogger({ prefix: [`[ensureFieldsExistOnMeeting]`] });
|
||||
const fieldSet = new Set(fieldsToTest);
|
||||
const foundFields: Array<{ name: string; type: string;[key: string]: any }> = [];
|
||||
const foundFields: Array<{ name: string; type: string; [key: string]: any }> = [];
|
||||
|
||||
try {
|
||||
const properties = await this.hubspotClient.crm.properties.coreApi.getAll("meetings");
|
||||
@@ -362,33 +364,6 @@ class HubspotCalendarService implements CRM {
|
||||
return this.hubspotClient.crm.objects.meetings.basicApi.archive(uid);
|
||||
};
|
||||
|
||||
private getHubspotOwnerIdByEmail = async (email: string): Promise<string | undefined> => {
|
||||
try {
|
||||
const ownersResponse = await this.hubspotClient.crm.owners.ownersApi.getPage(
|
||||
email,
|
||||
undefined,
|
||||
100,
|
||||
false
|
||||
);
|
||||
|
||||
if (ownersResponse.results && ownersResponse.results.length > 0) {
|
||||
const matchingOwner = ownersResponse.results.find(
|
||||
(owner) => owner.email?.toLowerCase() === email.toLowerCase()
|
||||
);
|
||||
|
||||
if (matchingOwner) {
|
||||
this.log.debug("hubspot:owner:found", { ownerId: matchingOwner.id });
|
||||
return matchingOwner.id;
|
||||
}
|
||||
}
|
||||
|
||||
this.log.debug("hubspot:owner:not_found");
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
this.log.error("hubspot:owner:lookup_error", { error });
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
private hubspotAuth = async (credential: CredentialPayload) => {
|
||||
const appKeys = await getAppKeysFromSlug("hubspot");
|
||||
@@ -455,14 +430,25 @@ class HubspotCalendarService implements CRM {
|
||||
const organizerEmail = event.organizer.email;
|
||||
this.log.debug("hubspot:meeting:fetching_owner");
|
||||
|
||||
const hubspotOwnerId = await this.getHubspotOwnerIdByEmail(organizerEmail);
|
||||
const hubspotOwnerId = await this.getHubspotOwnerIdFromEmail(organizerEmail);
|
||||
|
||||
const meetingEvent = await this.hubspotCreateMeeting(event, hubspotOwnerId);
|
||||
const meetingEvent = await this.hubspotCreateMeeting(event, hubspotOwnerId ?? undefined);
|
||||
if (meetingEvent) {
|
||||
this.log.debug("meeting:creation:ok", { meetingId: meetingEvent.id, hubspotOwnerId });
|
||||
const associatedMeeting = await this.hubspotAssociate(meetingEvent, contactIds as any);
|
||||
if (associatedMeeting) {
|
||||
this.log.debug("association:creation:ok", { associatedMeeting });
|
||||
|
||||
const firstContact = contacts[0];
|
||||
const appOptions = this.getAppOptions();
|
||||
if (hubspotOwnerId && firstContact?.id && appOptions?.setOrganizerAsOwner) {
|
||||
await this.setContactOwnerIfAllowed(
|
||||
firstContact.id,
|
||||
hubspotOwnerId,
|
||||
appOptions?.overwriteContactOwner ?? false
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
uid: meetingEvent.id,
|
||||
id: meetingEvent.id,
|
||||
@@ -581,6 +567,62 @@ class HubspotCalendarService implements CRM {
|
||||
async handleAttendeeNoShow() {
|
||||
console.log("Not implemented");
|
||||
}
|
||||
|
||||
private async getHubspotOwnerIdFromEmail(email: string): Promise<string | null> {
|
||||
try {
|
||||
const ownersResponse = await this.hubspotClient.crm.owners.ownersApi.getPage(
|
||||
email,
|
||||
undefined,
|
||||
100,
|
||||
false
|
||||
);
|
||||
if (ownersResponse.results && ownersResponse.results.length > 0) {
|
||||
const owner = ownersResponse.results.find((o) => o.email?.toLowerCase() === email.toLowerCase());
|
||||
return owner?.id ?? null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.log.error("Error fetching HubSpot owner:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async setContactOwner(contactId: string, ownerId: string): Promise<void> {
|
||||
try {
|
||||
await this.hubspotClient.crm.contacts.basicApi.update(contactId, {
|
||||
properties: { hubspot_owner_id: ownerId },
|
||||
});
|
||||
} catch (error) {
|
||||
this.log.error("Error setting contact owner:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getContactOwnerId(contactId: string): Promise<string | null> {
|
||||
const contact = await this.hubspotClient.crm.contacts.basicApi.getById(contactId, [
|
||||
"hubspot_owner_id",
|
||||
]);
|
||||
return contact.properties.hubspot_owner_id ?? null;
|
||||
}
|
||||
|
||||
private async setContactOwnerIfAllowed(
|
||||
contactId: string,
|
||||
ownerId: string,
|
||||
overwriteContactOwner: boolean
|
||||
): Promise<void> {
|
||||
if (overwriteContactOwner) {
|
||||
await this.setContactOwner(contactId, ownerId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentOwnerId = await this.getContactOwnerId(contactId);
|
||||
if (!currentOwnerId) {
|
||||
await this.setContactOwner(contactId, ownerId);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log.error("Error fetching contact owner, skipping owner update:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,8 @@ export { CrmFieldType, WhenToWrite, DateFieldType } from "../_lib/crm-enums";
|
||||
export const appDataSchema = eventTypeAppCardZod.extend({
|
||||
ignoreGuests: z.boolean().optional(),
|
||||
skipContactCreation: z.boolean().optional(),
|
||||
setOrganizerAsOwner: z.boolean().optional(),
|
||||
overwriteContactOwner: z.boolean().optional(),
|
||||
onBookingWriteToEventObject: z.boolean().optional(),
|
||||
onBookingWriteToEventObjectFields: z.record(z.string(), writeToBookingEntry).optional(),
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user