Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b3fbb672b | ||
|
|
2d5a86774e | ||
|
|
0865104a77 | ||
|
|
1914b37ee4 | ||
|
|
58ef0abb99 | ||
|
|
13ba680a73 | ||
|
|
f40c6da93b | ||
|
|
fc2e81e89a | ||
|
|
293dd1a140 | ||
|
|
b7fda31678 | ||
|
|
94a04f0dc2 | ||
|
|
2338f85dcf | ||
|
|
fbaccc8e5f | ||
|
|
750db38d38 |
@@ -1,9 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
Added new callback functions to the handleFormSubmit method in the EventTypeSettings and AvailabilitySettings atoms. The handleFormSubmit method now accepts an optional callbacks object with the following properties:
|
||||
|
||||
- **onSuccess**: Called when the form submission is successful, allowing additional logic to be executed after the update.
|
||||
|
||||
- **onError**: Called when an error occurs during form submission, providing details about the error to handle specific cases or display custom messages.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
feat: style calendar settings and availability overrides
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
booker atom: allow toggling org and team info when booking round robin
|
||||
Vendored
+2
-3
@@ -67,10 +67,9 @@ export class EventTypesPrivateLinksController {
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Get all private links for an event type" })
|
||||
async getPrivateLinks(
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@GetUser("id") userId: number
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
|
||||
): Promise<GetPrivateLinksOutput> {
|
||||
const privateLinks = await this.privateLinksService.getPrivateLinks(eventTypeId, userId);
|
||||
const privateLinks = await this.privateLinksService.getPrivateLinks(eventTypeId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { EventTypesPrivateLinksController } from "./controllers/event-types-private-links.controller";
|
||||
import { PrivateLinksRepository } from "./private-links.repository";
|
||||
import { PrivateLinksInputService } from "./services/private-links-input.service";
|
||||
import { PrivateLinksOutputService } from "./services/private-links-output.service";
|
||||
import { PrivateLinksService } from "./services/private-links.service";
|
||||
import { PrivateLinksRepository } from "./private-links.repository";
|
||||
|
||||
@Module({
|
||||
imports: [TokensModule, OAuthClientModule, PrismaModule, EventTypesModule_2024_06_14],
|
||||
@@ -23,5 +23,3 @@ import { PrivateLinksRepository } from "./private-links.repository";
|
||||
],
|
||||
})
|
||||
export class EventTypesPrivateLinksModule {}
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,10 @@ export class PrivateLinksRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async create(eventTypeId: number, link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }) {
|
||||
async create(
|
||||
eventTypeId: number,
|
||||
link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }
|
||||
) {
|
||||
return this.dbWrite.prisma.hashedLink.create({
|
||||
data: {
|
||||
eventTypeId,
|
||||
@@ -36,7 +39,10 @@ export class PrivateLinksRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async update(eventTypeId: number, link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }) {
|
||||
async update(
|
||||
eventTypeId: number,
|
||||
link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }
|
||||
) {
|
||||
return this.dbWrite.prisma.hashedLink.updateMany({
|
||||
where: { eventTypeId, link: link.link },
|
||||
data: {
|
||||
@@ -50,5 +56,3 @@ export class PrivateLinksRepository {
|
||||
return this.dbWrite.prisma.hashedLink.deleteMany({ where: { eventTypeId, link: linkId } });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+6
-3
@@ -1,6 +1,12 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import {
|
||||
PrivateLinkOutput,
|
||||
TimeBasedPrivateLinkOutput,
|
||||
UsageBasedPrivateLinkOutput,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
export type PrivateLinkData = {
|
||||
id: string;
|
||||
eventTypeId: number;
|
||||
@@ -10,7 +16,6 @@ export type PrivateLinkData = {
|
||||
maxUsageCount?: number | null;
|
||||
usageCount?: number;
|
||||
};
|
||||
import { PrivateLinkOutput, TimeBasedPrivateLinkOutput, UsageBasedPrivateLinkOutput } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class PrivateLinksOutputService {
|
||||
@@ -39,5 +44,3 @@ export class PrivateLinksOutputService {
|
||||
return data.map((item) => this.transformToOutput(item));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { generateHashedLink, isLinkExpired } from "@calcom/platform-libraries/private-links";
|
||||
import { CreatePrivateLinkInput, PrivateLinkOutput, UpdatePrivateLinkInput } from "@calcom/platform-types";
|
||||
|
||||
import { PrivateLinksRepository } from "@/ee/event-types-private-links/private-links.repository";
|
||||
import { PrivateLinksInputService } from "@/ee/event-types-private-links/services/private-links-input.service";
|
||||
import {
|
||||
PrivateLinksOutputService,
|
||||
type PrivateLinkData,
|
||||
} from "@/ee/event-types-private-links/services/private-links-output.service";
|
||||
import { PrivateLinksRepository } from "@/ee/event-types-private-links/private-links.repository";
|
||||
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { generateHashedLink, isLinkExpired } from "@calcom/platform-libraries/private-links";
|
||||
import { CreatePrivateLinkInput, PrivateLinkOutput, UpdatePrivateLinkInput } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class PrivateLinksService {
|
||||
@@ -48,7 +47,7 @@ export class PrivateLinksService {
|
||||
}
|
||||
}
|
||||
|
||||
async getPrivateLinks(eventTypeId: number, userId: number): Promise<PrivateLinkOutput[]> {
|
||||
async getPrivateLinks(eventTypeId: number): Promise<PrivateLinkOutput[]> {
|
||||
try {
|
||||
const links = await this.repo.listByEventTypeId(eventTypeId);
|
||||
const mapped: PrivateLinkData[] = links.map((l) => ({
|
||||
@@ -124,5 +123,3 @@ export class PrivateLinksService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
-2
@@ -6,7 +6,6 @@ import { UpdateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2
|
||||
import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_2024_06_14/pipes/event-type-response.transformer";
|
||||
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
|
||||
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
||||
|
||||
import { VERSION_2024_06_14_VALUE } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
@@ -42,7 +41,6 @@ import {
|
||||
GetEventTypesQuery_2024_06_14,
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
EventTypeOutput_2024_06_14,
|
||||
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BookingsModule_2024_04_15 } from "@/ee/bookings/2024-04-15/bookings.module";
|
||||
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
|
||||
import { CalendarsModule } from "@/ee/calendars/calendars.module";
|
||||
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
|
||||
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { GcalModule } from "@/ee/gcal/gcal.module";
|
||||
@@ -14,7 +15,6 @@ import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.m
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module";
|
||||
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
|
||||
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
|
||||
+52
-2
@@ -2,10 +2,21 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { GetUnifiedCalendarEventOutput } from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event";
|
||||
import { UpdateUnifiedCalendarEventInput } from "@/modules/cal-unified-calendars/inputs/update-unified-calendar-event.input";
|
||||
import { GetUnifiedCalendarEventOutput } from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output";
|
||||
import { GoogleCalendarEventOutputPipe } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
|
||||
import { GoogleCalendarService } from "@/modules/cal-unified-calendars/services/google-calendar.service";
|
||||
import { Controller, Get, Param, UseGuards, HttpCode, HttpStatus, BadRequestException } from "@nestjs/common";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
BadRequestException,
|
||||
Patch,
|
||||
Body,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags, ApiParam, ApiHeader, ApiOperation } from "@nestjs/swagger";
|
||||
|
||||
import { GOOGLE_CALENDAR, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
@@ -30,6 +41,7 @@ export class CalUnifiedCalendarsController {
|
||||
type: String,
|
||||
})
|
||||
@Get("/:calendar/event/:eventUid")
|
||||
@Get("/:calendar/events/:eventUid")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@@ -54,4 +66,42 @@ export class CalUnifiedCalendarsController {
|
||||
data: transformedEvent,
|
||||
};
|
||||
}
|
||||
|
||||
@ApiParam({
|
||||
name: "calendar",
|
||||
enum: [GOOGLE_CALENDAR],
|
||||
type: String,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "eventUid",
|
||||
description:
|
||||
"The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking",
|
||||
type: String,
|
||||
})
|
||||
@Patch("/:calendar/events/:eventUid")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Update meeting details in calendar",
|
||||
description: "Updates event information in the specified calendar provider",
|
||||
})
|
||||
async updateCalendarEvent(
|
||||
@Param("calendar") calendar: string,
|
||||
@Param("eventUid") eventUid: string,
|
||||
@Body() updateData: UpdateUnifiedCalendarEventInput
|
||||
): Promise<GetUnifiedCalendarEventOutput> {
|
||||
if (calendar !== GOOGLE_CALENDAR) {
|
||||
throw new BadRequestException("Event updates are currently only available for Google Calendar");
|
||||
}
|
||||
|
||||
const updatedEvent = await this.googleCalendarService.updateEventDetails(eventUid, updateData);
|
||||
|
||||
const transformedEvent = new GoogleCalendarEventOutputPipe().transform(updatedEvent);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: transformedEvent,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsISO8601, IsOptional, IsString, ValidateNested, IsEnum, IsArray } from "class-validator";
|
||||
|
||||
import { CalendarEventStatus, CalendarEventResponseStatus } from "../outputs/get-unified-calendar-event.output";
|
||||
|
||||
export class UpdateCalendarEventAttendee {
|
||||
@IsString()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "Email address of the attendee",
|
||||
})
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "Display name of the attendee",
|
||||
})
|
||||
name?: string;
|
||||
|
||||
@IsEnum(CalendarEventResponseStatus)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
enum: CalendarEventResponseStatus,
|
||||
enumName: "CalendarEventResponseStatus",
|
||||
nullable: true,
|
||||
description: "Response status of the attendee",
|
||||
})
|
||||
responseStatus?: CalendarEventResponseStatus | null;
|
||||
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Indicates if this attendee is the current user",
|
||||
})
|
||||
self?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Indicates if this attendee's attendance is optional",
|
||||
})
|
||||
optional?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Indicates if this attendee is the host",
|
||||
})
|
||||
host?: boolean;
|
||||
|
||||
}
|
||||
|
||||
export class UpdateDateTimeWithZone {
|
||||
@IsISO8601()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({ type: "string", format: "date-time" })
|
||||
time?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional()
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
export class UpdateUnifiedCalendarEventInput {
|
||||
@ValidateNested()
|
||||
@Type(() => UpdateDateTimeWithZone)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: "object",
|
||||
properties: {
|
||||
time: { type: "string", format: "date-time" },
|
||||
timeZone: { type: "string" },
|
||||
},
|
||||
description: "Start date and time of the calendar event with timezone information",
|
||||
})
|
||||
start?: UpdateDateTimeWithZone;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => UpdateDateTimeWithZone)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: "object",
|
||||
properties: {
|
||||
time: { type: "string", format: "date-time" },
|
||||
timeZone: { type: "string" },
|
||||
},
|
||||
description: "End date and time of the calendar event with timezone information",
|
||||
})
|
||||
end?: UpdateDateTimeWithZone;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "Title of the calendar event",
|
||||
})
|
||||
title?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description: "Detailed description of the calendar event",
|
||||
})
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UpdateCalendarEventAttendee)
|
||||
@ApiPropertyOptional({
|
||||
type: [UpdateCalendarEventAttendee],
|
||||
nullable: true,
|
||||
description:
|
||||
"List of attendees. CAUTION: You must pass the entire array with all updated values. Any attendees not included in this array will be removed from the event.",
|
||||
})
|
||||
attendees?: UpdateCalendarEventAttendee[];
|
||||
|
||||
@IsEnum(CalendarEventStatus)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
enum: CalendarEventStatus,
|
||||
enumName: "CalendarEventStatus",
|
||||
nullable: true,
|
||||
description: "Status of the event (accepted, pending, declined, cancelled)",
|
||||
example: CalendarEventStatus.ACCEPTED,
|
||||
})
|
||||
status?: CalendarEventStatus | null;
|
||||
}
|
||||
+34
@@ -225,6 +225,22 @@ export class CalendarEventHost {
|
||||
responseStatus!: CalendarEventResponseStatus | null;
|
||||
}
|
||||
|
||||
export class calendarEventOwner {
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
description: "Email address of the event host",
|
||||
})
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Display name of the event host",
|
||||
})
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export class CalendarEventAttendee {
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
@@ -264,6 +280,14 @@ export class CalendarEventAttendee {
|
||||
description: "Indicates if this attendee's attendance is optional",
|
||||
})
|
||||
optional?: boolean;
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: "Indicates if this attendee is the host",
|
||||
})
|
||||
host?: boolean;
|
||||
}
|
||||
|
||||
export class DateTimeWithZone {
|
||||
@@ -384,6 +408,16 @@ export class UnifiedCalendarEventOutput {
|
||||
})
|
||||
hosts?: CalendarEventHost[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => calendarEventOwner)
|
||||
@ApiPropertyOptional({
|
||||
type: calendarEventOwner,
|
||||
nullable: true,
|
||||
description: "The calendar account that owns this event. This is the primary calendar where the event is stored and cannot be modified without appropriate permissions. Changing this would require moving the event to a different calendar",
|
||||
})
|
||||
calendarEventOwner?: calendarEventOwner;
|
||||
|
||||
@IsEnum(CALENDARS)
|
||||
@ApiProperty({
|
||||
enum: CALENDARS,
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { GoogleCalendarEventResponse } from "../get-calendar-event-details-output-pipe";
|
||||
|
||||
export const createGoogleCalendarEventFixture = (
|
||||
overrides: Partial<GoogleCalendarEventResponse> = {}
|
||||
): GoogleCalendarEventResponse => {
|
||||
const baseEvent: GoogleCalendarEventResponse = {
|
||||
kind: "calendar#event",
|
||||
etag: "test-etag",
|
||||
id: "test-event-id",
|
||||
status: "confirmed",
|
||||
htmlLink: "https://calendar.google.com/event",
|
||||
created: "2024-01-01T00:00:00Z",
|
||||
updated: "2024-01-01T00:00:00Z",
|
||||
summary: "Test Meeting",
|
||||
description: "Test description",
|
||||
creator: {
|
||||
email: "creator@example.com",
|
||||
displayName: "Creator Name",
|
||||
},
|
||||
organizer: {
|
||||
email: "organizer@example.com",
|
||||
displayName: "Organizer Name",
|
||||
},
|
||||
start: {
|
||||
dateTime: "2024-01-15T10:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
end: {
|
||||
dateTime: "2024-01-15T11:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
iCalUID: "test-ical-uid",
|
||||
sequence: 0,
|
||||
attendees: [
|
||||
{
|
||||
email: "attendee@example.com",
|
||||
displayName: "Attendee Name",
|
||||
responseStatus: "accepted",
|
||||
organizer: false,
|
||||
},
|
||||
{
|
||||
email: "organizer@example.com",
|
||||
displayName: "Organizer Name",
|
||||
responseStatus: "accepted",
|
||||
organizer: true,
|
||||
},
|
||||
],
|
||||
conferenceData: {
|
||||
conferenceId: "abc-def-ghi",
|
||||
entryPoints: [
|
||||
{
|
||||
entryPointType: "video",
|
||||
uri: "https://meet.google.com/abc-def-ghi",
|
||||
label: "meet.google.com/abc-def-ghi",
|
||||
},
|
||||
],
|
||||
conferenceSolution: {
|
||||
key: {
|
||||
type: "hangoutsMeet",
|
||||
},
|
||||
name: "Google Meet",
|
||||
iconUri:
|
||||
"https://fonts.gstatic.com/s/i/productlogos/meet_2020q4/v6/web-512dp/logo_meet_2020q4_color_2x_web_512dp.png",
|
||||
},
|
||||
},
|
||||
hangoutLink: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
return { ...baseEvent, ...overrides };
|
||||
};
|
||||
|
||||
export const googleEventWithConferenceData = createGoogleCalendarEventFixture({
|
||||
conferenceData: {
|
||||
conferenceId: "test-conference-id",
|
||||
entryPoints: [
|
||||
{
|
||||
entryPointType: "video",
|
||||
uri: "https://meet.google.com/abc-def-ghi",
|
||||
label: "Google Meet",
|
||||
pin: "123456",
|
||||
regionCode: "US",
|
||||
},
|
||||
{
|
||||
entryPointType: "phone",
|
||||
uri: "tel:+1-555-123-4567",
|
||||
label: "Phone",
|
||||
pin: "789012",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const googleEventWithLocationOnly = createGoogleCalendarEventFixture({
|
||||
location: "123 Main St, City, State",
|
||||
conferenceData: undefined,
|
||||
hangoutLink: undefined,
|
||||
});
|
||||
|
||||
export const googleEventWithHangoutLink = createGoogleCalendarEventFixture({
|
||||
hangoutLink: "https://hangouts.google.com/call/abc123",
|
||||
conferenceData: undefined,
|
||||
location: undefined,
|
||||
});
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
CalendarEventStatus,
|
||||
CalendarEventResponseStatus,
|
||||
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output";
|
||||
|
||||
import {
|
||||
createGoogleCalendarEventFixture,
|
||||
googleEventWithConferenceData,
|
||||
googleEventWithLocationOnly,
|
||||
googleEventWithHangoutLink,
|
||||
} from "./__fixtures__/google-calendar-event.fixture";
|
||||
import {
|
||||
GoogleCalendarEventOutputPipe,
|
||||
GoogleCalendarEventResponse,
|
||||
} from "./get-calendar-event-details-output-pipe";
|
||||
|
||||
describe("GoogleCalendarEventOutputPipe", () => {
|
||||
let pipe: GoogleCalendarEventOutputPipe;
|
||||
let sharedGoogleEvent: GoogleCalendarEventResponse;
|
||||
|
||||
beforeAll(() => {
|
||||
sharedGoogleEvent = createGoogleCalendarEventFixture();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
pipe = new GoogleCalendarEventOutputPipe();
|
||||
});
|
||||
|
||||
describe("transform", () => {
|
||||
it("should transform Google Calendar event to unified format", () => {
|
||||
const result = pipe.transform(sharedGoogleEvent);
|
||||
|
||||
expect(result.id).toBe("test-event-id");
|
||||
expect(result.title).toBe("Test Meeting");
|
||||
expect(result.description).toBe("Test description");
|
||||
expect(result.source).toBe("google");
|
||||
expect(result.status).toBe(CalendarEventStatus.ACCEPTED);
|
||||
expect(result.start.time).toBe("2024-01-15T10:00:00Z");
|
||||
expect(result.start.timeZone).toBe("America/New_York");
|
||||
expect(result.end.time).toBe("2024-01-15T11:00:00Z");
|
||||
expect(result.end.timeZone).toBe("America/New_York");
|
||||
expect(result.attendees).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformDateTimeWithZone", () => {
|
||||
it("should transform Google date time to unified format", () => {
|
||||
const googleDateTime = {
|
||||
dateTime: "2024-01-15T10:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
};
|
||||
|
||||
const result = pipe["transformDateTimeWithZone"](googleDateTime);
|
||||
|
||||
expect(result.time).toBe("2024-01-15T10:00:00Z");
|
||||
expect(result.timeZone).toBe("America/New_York");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformAttendeeResponseStatus", () => {
|
||||
it("should transform accepted status", () => {
|
||||
const result = pipe["transformAttendeeResponseStatus"]("accepted");
|
||||
expect(result).toBe(CalendarEventResponseStatus.ACCEPTED);
|
||||
});
|
||||
|
||||
it("should transform tentative status", () => {
|
||||
const result = pipe["transformAttendeeResponseStatus"]("tentative");
|
||||
expect(result).toBe(CalendarEventResponseStatus.PENDING);
|
||||
});
|
||||
|
||||
it("should transform declined status", () => {
|
||||
const result = pipe["transformAttendeeResponseStatus"]("declined");
|
||||
expect(result).toBe(CalendarEventResponseStatus.DECLINED);
|
||||
});
|
||||
|
||||
it("should transform needsaction status", () => {
|
||||
const result = pipe["transformAttendeeResponseStatus"]("needsaction");
|
||||
expect(result).toBe(CalendarEventResponseStatus.NEEDS_ACTION);
|
||||
});
|
||||
|
||||
it("should handle case insensitive status", () => {
|
||||
const result = pipe["transformAttendeeResponseStatus"]("ACCEPTED");
|
||||
expect(result).toBe(CalendarEventResponseStatus.ACCEPTED);
|
||||
});
|
||||
|
||||
it("should handle unknown status", () => {
|
||||
const result = pipe["transformAttendeeResponseStatus"]("unknown");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle null status", () => {
|
||||
const result = pipe["transformAttendeeResponseStatus"](undefined);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformEventStatus", () => {
|
||||
it("should transform confirmed status", () => {
|
||||
const result = pipe["transformEventStatus"]("confirmed");
|
||||
expect(result).toBe(CalendarEventStatus.ACCEPTED);
|
||||
});
|
||||
|
||||
it("should transform tentative status", () => {
|
||||
const result = pipe["transformEventStatus"]("tentative");
|
||||
expect(result).toBe(CalendarEventStatus.PENDING);
|
||||
});
|
||||
|
||||
it("should transform cancelled status", () => {
|
||||
const result = pipe["transformEventStatus"]("cancelled");
|
||||
expect(result).toBe(CalendarEventStatus.CANCELLED);
|
||||
});
|
||||
|
||||
it("should handle case insensitive status", () => {
|
||||
const result = pipe["transformEventStatus"]("CONFIRMED");
|
||||
expect(result).toBe(CalendarEventStatus.ACCEPTED);
|
||||
});
|
||||
|
||||
it("should handle unknown status", () => {
|
||||
const result = pipe["transformEventStatus"]("unknown");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle null status", () => {
|
||||
const result = pipe["transformEventStatus"](undefined);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformOrganizer", () => {
|
||||
it("should return calendarEventOwner organizer exist", () => {
|
||||
const eventWithoutOrganizerAttendees = createGoogleCalendarEventFixture({
|
||||
organizer: { email: "organizer@example.com" },
|
||||
attendees: [
|
||||
{
|
||||
email: "attendee@example.com",
|
||||
displayName: "Regular Attendee",
|
||||
responseStatus: "accepted",
|
||||
organizer: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = pipe.transform(eventWithoutOrganizerAttendees);
|
||||
|
||||
expect(result?.calendarEventOwner?.email).toBe("organizer@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformLocations", () => {
|
||||
it("should transform conference data entry points", () => {
|
||||
const result = pipe["transformLocations"](googleEventWithConferenceData);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
type: "video",
|
||||
url: "https://meet.google.com/abc-def-ghi",
|
||||
label: "Google Meet",
|
||||
pin: "123456",
|
||||
regionCode: "US",
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
type: "phone",
|
||||
url: "tel:+1-555-123-4567",
|
||||
label: "Phone",
|
||||
pin: "789012",
|
||||
regionCode: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should fallback to location field", () => {
|
||||
const result = pipe["transformLocations"](googleEventWithLocationOnly);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: "video",
|
||||
url: "123 Main St, City, State",
|
||||
});
|
||||
});
|
||||
|
||||
it("should fallback to hangout link", () => {
|
||||
const result = pipe["transformLocations"](googleEventWithHangoutLink);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: "video",
|
||||
url: "https://hangouts.google.com/call/abc123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty array when no location data", () => {
|
||||
const eventWithoutLocation = createGoogleCalendarEventFixture({
|
||||
location: undefined,
|
||||
conferenceData: undefined,
|
||||
hangoutLink: undefined,
|
||||
});
|
||||
|
||||
const result = pipe["transformLocations"](eventWithoutLocation);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+7
-6
@@ -3,7 +3,7 @@ import {
|
||||
CalendarEventStatus,
|
||||
DateTimeWithZone,
|
||||
UnifiedCalendarEventOutput,
|
||||
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event";
|
||||
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output";
|
||||
import { PipeTransform, Injectable } from "@nestjs/common";
|
||||
|
||||
export interface GoogleCalendarEventResponse {
|
||||
@@ -94,14 +94,12 @@ export class GoogleCalendarEventOutputPipe
|
||||
calendarEvent.locations = this.transformLocations(googleEvent);
|
||||
|
||||
if (googleEvent.attendees && googleEvent.attendees.length > 0) {
|
||||
calendarEvent.attendees = googleEvent.attendees
|
||||
.filter((attendee) => !attendee.organizer)
|
||||
.map((attendee) => {
|
||||
calendarEvent.attendees = googleEvent.attendees.map((attendee) => {
|
||||
return {
|
||||
email: attendee.email,
|
||||
name: attendee.displayName,
|
||||
responseStatus: this.transformAttendeeResponseStatus(attendee.responseStatus),
|
||||
organizer: attendee.organizer,
|
||||
host: attendee.organizer,
|
||||
self: attendee.self,
|
||||
optional: attendee.optional,
|
||||
};
|
||||
@@ -110,7 +108,8 @@ export class GoogleCalendarEventOutputPipe
|
||||
|
||||
calendarEvent.status = this.transformEventStatus(googleEvent.status);
|
||||
|
||||
calendarEvent.hosts = this.transformHosts(googleEvent);
|
||||
calendarEvent.calendarEventOwner = googleEvent.organizer
|
||||
calendarEvent.hosts = this.transformHosts(googleEvent)
|
||||
|
||||
calendarEvent.source = "google";
|
||||
|
||||
@@ -196,6 +195,8 @@ export class GoogleCalendarEventOutputPipe
|
||||
regionCode: entryPoint.regionCode,
|
||||
};
|
||||
});
|
||||
} else if (googleEvent.location) {
|
||||
return [{ type: "video", url: googleEvent.location }];
|
||||
} else if (googleEvent.hangoutLink) {
|
||||
return [{ type: "video", url: googleEvent.hangoutLink }];
|
||||
}
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { GoogleCalendarEventResponse } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
UpdateUnifiedCalendarEventInput,
|
||||
UpdateDateTimeWithZone,
|
||||
} from "../inputs/update-unified-calendar-event.input";
|
||||
import { CalendarEventResponseStatus, CalendarEventStatus } from "../outputs/get-unified-calendar-event.output";
|
||||
|
||||
// Common interfaces for Google Calendar types
|
||||
interface GoogleCalendarDateTime {
|
||||
dateTime: string;
|
||||
timeZone: string;
|
||||
}
|
||||
|
||||
interface GoogleCalendarAttendee {
|
||||
email: string;
|
||||
displayName?: string;
|
||||
responseStatus: string;
|
||||
organizer?: boolean;
|
||||
}
|
||||
|
||||
interface GoogleCalendarEventInputTransform {
|
||||
transform(
|
||||
updateData: UpdateUnifiedCalendarEventInput,
|
||||
fetchedEvent?: GoogleCalendarEventResponse | null
|
||||
): any;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GoogleCalendarEventInputPipe implements GoogleCalendarEventInputTransform {
|
||||
transform(
|
||||
updateData: UpdateUnifiedCalendarEventInput,
|
||||
): any {
|
||||
const updatePayload: any = {};
|
||||
|
||||
if (updateData.title !== undefined) {
|
||||
updatePayload.summary = updateData.title;
|
||||
}
|
||||
|
||||
if (updateData.description !== undefined) {
|
||||
updatePayload.description = updateData.description;
|
||||
}
|
||||
|
||||
if (updateData.start) {
|
||||
updatePayload.start = this.transformDateTimeWithZone(updateData.start);
|
||||
}
|
||||
|
||||
if (updateData.end) {
|
||||
updatePayload.end = this.transformDateTimeWithZone(updateData.end);
|
||||
}
|
||||
|
||||
if (updateData.attendees !== undefined) {
|
||||
updatePayload.attendees = this.transformAttendees(
|
||||
updateData.attendees,
|
||||
);
|
||||
}
|
||||
|
||||
if (updateData.status !== undefined) {
|
||||
updatePayload.status = this.transformEventStatus(updateData.status);
|
||||
}
|
||||
|
||||
return updatePayload;
|
||||
}
|
||||
|
||||
private transformDateTimeWithZone(dateTime: UpdateDateTimeWithZone): GoogleCalendarDateTime {
|
||||
return {
|
||||
dateTime: dateTime.time || "",
|
||||
timeZone: dateTime.timeZone || "",
|
||||
};
|
||||
}
|
||||
|
||||
private transformAttendees(
|
||||
inputAttendees: NonNullable<UpdateUnifiedCalendarEventInput["attendees"]>,
|
||||
): GoogleCalendarAttendee[] {
|
||||
return inputAttendees.map((attendee) => {
|
||||
return {
|
||||
email: attendee.email,
|
||||
displayName: attendee.name,
|
||||
responseStatus: this.transformResponseStatus(attendee.responseStatus),
|
||||
organizer: attendee.host,
|
||||
self: attendee.self,
|
||||
optional: attendee.optional,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private transformResponseStatus(responseStatus?: CalendarEventResponseStatus | null): string {
|
||||
if (!responseStatus) return "needsAction";
|
||||
|
||||
switch (responseStatus) {
|
||||
case CalendarEventResponseStatus.ACCEPTED:
|
||||
return "accepted";
|
||||
case CalendarEventResponseStatus.PENDING:
|
||||
return "tentative";
|
||||
case CalendarEventResponseStatus.DECLINED:
|
||||
return "declined";
|
||||
case CalendarEventResponseStatus.NEEDS_ACTION:
|
||||
return "needsAction";
|
||||
default:
|
||||
return "needsAction";
|
||||
}
|
||||
}
|
||||
|
||||
private transformEventStatus(status?: CalendarEventStatus | null): string {
|
||||
if (!status) return "confirmed";
|
||||
|
||||
switch (status) {
|
||||
case CalendarEventStatus.ACCEPTED:
|
||||
return "confirmed";
|
||||
case CalendarEventStatus.PENDING:
|
||||
return "tentative";
|
||||
case CalendarEventStatus.CANCELLED:
|
||||
return "cancelled";
|
||||
case CalendarEventStatus.DECLINED:
|
||||
return "cancelled";
|
||||
default:
|
||||
return "confirmed";
|
||||
}
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
import {
|
||||
UpdateUnifiedCalendarEventInput,
|
||||
UpdateDateTimeWithZone,
|
||||
} from "../inputs/update-unified-calendar-event.input";
|
||||
import { CalendarEventResponseStatus, CalendarEventStatus } from "../outputs/get-unified-calendar-event.output";
|
||||
import { GoogleCalendarEventResponse } from "./get-calendar-event-details-output-pipe";
|
||||
import { GoogleCalendarEventInputPipe } from "./google-calendar-event-input-pipe";
|
||||
|
||||
describe("GoogleCalendarEventInputPipe", () => {
|
||||
let pipe: GoogleCalendarEventInputPipe;
|
||||
let sharedGoogleEvent: GoogleCalendarEventResponse;
|
||||
|
||||
beforeAll(() => {
|
||||
sharedGoogleEvent = {
|
||||
kind: "calendar#event",
|
||||
etag: "test-etag",
|
||||
id: "test-event-id",
|
||||
status: "confirmed",
|
||||
htmlLink: "https://calendar.google.com/event",
|
||||
created: "2024-01-01T00:00:00Z",
|
||||
updated: "2024-01-01T00:00:00Z",
|
||||
summary: "Test Meeting",
|
||||
description: "Test description",
|
||||
start: {
|
||||
dateTime: "2024-01-15T10:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
end: {
|
||||
dateTime: "2024-01-15T11:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
iCalUID: "test-ical-uid",
|
||||
sequence: 0,
|
||||
attendees: [
|
||||
{
|
||||
email: "attendee1@example.com",
|
||||
displayName: "Attendee One",
|
||||
responseStatus: "accepted",
|
||||
organizer: false,
|
||||
},
|
||||
{
|
||||
email: "attendee2@example.com",
|
||||
displayName: "Attendee Two",
|
||||
responseStatus: "needsAction",
|
||||
organizer: false,
|
||||
},
|
||||
{
|
||||
email: "organizer@example.com",
|
||||
displayName: "Organizer",
|
||||
responseStatus: "accepted",
|
||||
organizer: true,
|
||||
},
|
||||
],
|
||||
creator: {
|
||||
email: "organizer@example.com",
|
||||
displayName: "Organizer",
|
||||
},
|
||||
organizer: {
|
||||
email: "organizer@example.com",
|
||||
displayName: "Organizer",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
pipe = new GoogleCalendarEventInputPipe();
|
||||
});
|
||||
|
||||
describe("transform", () => {
|
||||
it("should transform basic event fields", () => {
|
||||
const input: UpdateUnifiedCalendarEventInput = {
|
||||
title: "Updated Meeting",
|
||||
description: "Updated description",
|
||||
start: {
|
||||
time: "2024-01-15T10:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
end: {
|
||||
time: "2024-01-15T11:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
status: CalendarEventStatus.ACCEPTED,
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
summary: "Updated Meeting",
|
||||
description: "Updated description",
|
||||
start: {
|
||||
dateTime: "2024-01-15T10:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
end: {
|
||||
dateTime: "2024-01-15T11:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
status: "confirmed",
|
||||
};
|
||||
|
||||
const result = pipe.transform(input);
|
||||
expect(result).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it("should handle partial updates", () => {
|
||||
const input: UpdateUnifiedCalendarEventInput = {
|
||||
title: "Only Title Update",
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
summary: "Only Title Update",
|
||||
};
|
||||
|
||||
const result = pipe.transform(input);
|
||||
expect(result).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it("should handle null description", () => {
|
||||
const input: UpdateUnifiedCalendarEventInput = {
|
||||
description: null,
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
description: null,
|
||||
};
|
||||
|
||||
const result = pipe.transform(input);
|
||||
expect(result).toEqual(expectedOutput);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformDateTimeWithZone", () => {
|
||||
it("should transform date time with timezone", () => {
|
||||
const input: UpdateDateTimeWithZone = {
|
||||
time: "2024-01-15T10:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
dateTime: "2024-01-15T10:00:00Z",
|
||||
timeZone: "America/New_York",
|
||||
};
|
||||
|
||||
const result = pipe["transformDateTimeWithZone"](input);
|
||||
expect(result).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it("should handle empty time and timezone", () => {
|
||||
const input: UpdateDateTimeWithZone = {};
|
||||
|
||||
const expectedOutput = {
|
||||
dateTime: "",
|
||||
timeZone: "",
|
||||
};
|
||||
|
||||
const result = pipe["transformDateTimeWithZone"](input);
|
||||
expect(result).toEqual(expectedOutput);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformResponseStatus", () => {
|
||||
it("should transform ACCEPTED status", () => {
|
||||
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.ACCEPTED);
|
||||
expect(result).toBe("accepted");
|
||||
});
|
||||
|
||||
it("should transform PENDING status", () => {
|
||||
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.PENDING);
|
||||
expect(result).toBe("tentative");
|
||||
});
|
||||
|
||||
it("should transform DECLINED status", () => {
|
||||
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.DECLINED);
|
||||
expect(result).toBe("declined");
|
||||
});
|
||||
|
||||
it("should transform NEEDS_ACTION status", () => {
|
||||
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.NEEDS_ACTION);
|
||||
expect(result).toBe("needsAction");
|
||||
});
|
||||
|
||||
it("should handle null status", () => {
|
||||
const result = pipe["transformResponseStatus"](null);
|
||||
expect(result).toBe("needsAction");
|
||||
});
|
||||
|
||||
it("should handle undefined status", () => {
|
||||
const result = pipe["transformResponseStatus"](undefined);
|
||||
expect(result).toBe("needsAction");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformEventStatus", () => {
|
||||
it("should transform ACCEPTED status", () => {
|
||||
const result = pipe["transformEventStatus"](CalendarEventStatus.ACCEPTED);
|
||||
expect(result).toBe("confirmed");
|
||||
});
|
||||
|
||||
it("should transform PENDING status", () => {
|
||||
const result = pipe["transformEventStatus"](CalendarEventStatus.PENDING);
|
||||
expect(result).toBe("tentative");
|
||||
});
|
||||
|
||||
it("should transform CANCELLED status", () => {
|
||||
const result = pipe["transformEventStatus"](CalendarEventStatus.CANCELLED);
|
||||
expect(result).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("should transform DECLINED status", () => {
|
||||
const result = pipe["transformEventStatus"](CalendarEventStatus.DECLINED);
|
||||
expect(result).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("should handle null status", () => {
|
||||
const result = pipe["transformEventStatus"](null);
|
||||
expect(result).toBe("confirmed");
|
||||
});
|
||||
|
||||
it("should handle undefined status", () => {
|
||||
const result = pipe["transformEventStatus"](undefined);
|
||||
expect(result).toBe("confirmed");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,8 @@ import { JWT } from "googleapis-common";
|
||||
|
||||
import { DelegationCredentialRepository, OAuth2UniversalSchema } from "@calcom/platform-libraries/app-store";
|
||||
|
||||
import { UnifiedCalendarEventOutput } from "../outputs/get-unified-calendar-event";
|
||||
import { UpdateUnifiedCalendarEventInput } from "../inputs/update-unified-calendar-event.input";
|
||||
import { GoogleCalendarEventInputPipe } from "../pipes/google-calendar-event-input-pipe";
|
||||
|
||||
@Injectable()
|
||||
export class GoogleCalendarService {
|
||||
@@ -53,6 +54,44 @@ export class GoogleCalendarService {
|
||||
}
|
||||
}
|
||||
|
||||
async updateEventDetails(
|
||||
eventUid: string,
|
||||
updateData: UpdateUnifiedCalendarEventInput
|
||||
): Promise<GoogleCalendarEventResponse> {
|
||||
const bookingReference =
|
||||
await this.bookingReferencesRepository.getBookingReferencesIncludeSensitiveCredentials(eventUid);
|
||||
|
||||
if (!bookingReference) {
|
||||
throw new NotFoundException("Booking reference not found");
|
||||
}
|
||||
|
||||
const ownerUserEmail = bookingReference?.booking?.user?.email;
|
||||
|
||||
const calendar = await this.getAuthorizedCalendarInstance(
|
||||
ownerUserEmail,
|
||||
bookingReference.credential?.key,
|
||||
bookingReference.delegationCredential
|
||||
);
|
||||
|
||||
|
||||
const updatePayload = new GoogleCalendarEventInputPipe().transform(updateData);
|
||||
|
||||
try {
|
||||
const event = await calendar.events.patch({
|
||||
calendarId: bookingReference?.externalCalendarId ?? "primary",
|
||||
eventId: bookingReference?.uid,
|
||||
requestBody: updatePayload,
|
||||
});
|
||||
|
||||
if (!event.data) {
|
||||
throw new NotFoundException("Failed to update meeting");
|
||||
}
|
||||
return event.data as GoogleCalendarEventResponse;
|
||||
} catch (error) {
|
||||
throw new NotFoundException("Failed to update meeting details");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an authorized Google Calendar instance
|
||||
* Tries delegation credentials first, falls back to direct OAuth
|
||||
|
||||
@@ -40,5 +40,3 @@ export class EventTypeOwnershipGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8563,6 +8563,69 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/calendars/{calendar}/events/{eventUid}": {
|
||||
"patch": {
|
||||
"operationId": "CalUnifiedCalendarsController_updateCalendarEvent",
|
||||
"summary": "Update meeting details in calendar",
|
||||
"description": "Updates event information in the specified calendar provider",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "calendar",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"google"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "eventUid",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateUnifiedCalendarEventInput"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GetUnifiedCalendarEventOutput"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Cal Unified Calendars"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/calendars/ics-feed/save": {
|
||||
"post": {
|
||||
"operationId": "CalendarsController_createIcsFeed",
|
||||
@@ -28782,7 +28845,7 @@
|
||||
},
|
||||
"CalendarEventResponseStatus": {
|
||||
"type": "string",
|
||||
"description": "Host's response to the invitation",
|
||||
"description": "Response status of the attendee",
|
||||
"enum": [
|
||||
"accepted",
|
||||
"pending",
|
||||
@@ -28815,6 +28878,11 @@
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "Indicates if this attendee's attendance is optional"
|
||||
},
|
||||
"host": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "Indicates if this attendee is the host"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -28853,6 +28921,23 @@
|
||||
"email"
|
||||
]
|
||||
},
|
||||
"calendarEventOwner": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"description": "Email address of the event host"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Display name of the event host"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email"
|
||||
]
|
||||
},
|
||||
"CalendarSource": {
|
||||
"type": "string",
|
||||
"description": "Calendar integration source (e.g., Google Calendar, Office 365, Apple Calendar). Currently only Google Calendar is supported.",
|
||||
@@ -28949,6 +29034,15 @@
|
||||
"$ref": "#/components/schemas/CalendarEventHost"
|
||||
}
|
||||
},
|
||||
"calendarEventOwner": {
|
||||
"nullable": true,
|
||||
"description": "The calendar account that owns this event. This is the primary calendar where the event is stored and cannot be modified without appropriate permissions. Changing this would require moving the event to a different calendar",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/calendarEventOwner"
|
||||
}
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"example": "google",
|
||||
"$ref": "#/components/schemas/CalendarSource"
|
||||
@@ -28982,6 +29076,91 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"UpdateCalendarEventAttendee": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"description": "Email address of the attendee"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Display name of the attendee"
|
||||
},
|
||||
"responseStatus": {
|
||||
"nullable": true,
|
||||
"$ref": "#/components/schemas/CalendarEventResponseStatus"
|
||||
},
|
||||
"self": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "Indicates if this attendee is the current user"
|
||||
},
|
||||
"optional": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "Indicates if this attendee's attendance is optional"
|
||||
},
|
||||
"host": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "Indicates if this attendee is the host"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateUnifiedCalendarEventInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"timeZone": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Start date and time of the calendar event with timezone information"
|
||||
},
|
||||
"end": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"timeZone": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "End date and time of the calendar event with timezone information"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Title of the calendar event"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Detailed description of the calendar event"
|
||||
},
|
||||
"attendees": {
|
||||
"nullable": true,
|
||||
"description": "List of attendees. CAUTION: You must pass the entire array with all updated values. Any attendees not included in this array will be removed from the event.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/UpdateCalendarEventAttendee"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"nullable": true,
|
||||
"example": "accepted",
|
||||
"$ref": "#/components/schemas/CalendarEventStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RequestEmailVerificationInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { type TFunction } from "i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { List } from "@calcom/ui/components/list";
|
||||
@@ -12,10 +16,18 @@ import { StepConnectionLoader } from "../components/StepConnectionLoader";
|
||||
interface ConnectedAppStepProps {
|
||||
nextStep: () => void;
|
||||
isPageLoading: boolean;
|
||||
user: RouterOutputs["viewer"]["me"]["get"];
|
||||
}
|
||||
|
||||
const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
||||
const { nextStep, isPageLoading } = props;
|
||||
const ConnectedVideoStepInner = ({
|
||||
t,
|
||||
setAnyInstalledVideoApps,
|
||||
user,
|
||||
}: {
|
||||
t: TFunction;
|
||||
setAnyInstalledVideoApps: Dispatch<SetStateAction<boolean>>;
|
||||
user: RouterOutputs["viewer"]["me"]["get"];
|
||||
}) => {
|
||||
const { data: queryConnectedVideoApps, isPending } = trpc.viewer.apps.integrations.useQuery({
|
||||
variant: "conferencing",
|
||||
onlyInstalled: false,
|
||||
@@ -30,47 +42,59 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
||||
sortByMostPopular: true,
|
||||
sortByInstalledFirst: true,
|
||||
});
|
||||
const { data } = useMeQuery();
|
||||
const { t } = useLocale();
|
||||
|
||||
const metadata = userMetadata.parse(data?.metadata);
|
||||
|
||||
const hasAnyInstalledVideoApps = queryConnectedVideoApps?.items.some(
|
||||
(item) => item.userCredentialIds.length > 0
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setAnyInstalledVideoApps(Boolean(hasAnyInstalledVideoApps));
|
||||
}, [hasAnyInstalledVideoApps, setAnyInstalledVideoApps]);
|
||||
|
||||
if (isPending) {
|
||||
return <StepConnectionLoader />;
|
||||
}
|
||||
|
||||
const result = userMetadata.safeParse(user.metadata);
|
||||
if (!result.success) {
|
||||
return <StepConnectionLoader />;
|
||||
}
|
||||
const { data: metadata } = result;
|
||||
const defaultConferencingApp = metadata?.defaultConferencingApp?.appSlug;
|
||||
return (
|
||||
<>
|
||||
{!isPending && (
|
||||
<List className="bg-default border-subtle divide-subtle scroll-bar mx-1 max-h-[45vh] divide-y !overflow-y-scroll rounded-md border p-0 sm:mx-0">
|
||||
{queryConnectedVideoApps?.items &&
|
||||
queryConnectedVideoApps?.items.map((item) => {
|
||||
if (item.slug === "daily-video") return null; // we dont want to show daily here as it is installed by default
|
||||
return (
|
||||
<li key={item.name}>
|
||||
{item.name && item.logo && (
|
||||
<AppConnectionItem
|
||||
type={item.type}
|
||||
title={item.name}
|
||||
isDefault={item.slug === defaultConferencingApp}
|
||||
description={item.description}
|
||||
dependencyData={item.dependencyData}
|
||||
logo={item.logo}
|
||||
slug={item.slug}
|
||||
installed={item.userCredentialIds.length > 0}
|
||||
defaultInstall={
|
||||
!defaultConferencingApp && item.appData?.location?.linkType === "dynamic"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
<List className="bg-default border-subtle divide-subtle scroll-bar mx-1 max-h-[45vh] divide-y !overflow-y-scroll rounded-md border p-0 sm:mx-0">
|
||||
{queryConnectedVideoApps?.items &&
|
||||
queryConnectedVideoApps?.items.map((item) => {
|
||||
if (item.slug === "daily-video") return null; // we dont want to show daily here as it is installed by default
|
||||
return (
|
||||
<li key={item.name}>
|
||||
{item.name && item.logo && (
|
||||
<AppConnectionItem
|
||||
type={item.type}
|
||||
title={item.name}
|
||||
isDefault={item.slug === defaultConferencingApp}
|
||||
description={item.description}
|
||||
dependencyData={item.dependencyData}
|
||||
logo={item.logo}
|
||||
slug={item.slug}
|
||||
installed={item.userCredentialIds.length > 0}
|
||||
defaultInstall={!defaultConferencingApp && item.appData?.location?.linkType === "dynamic"}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
{isPending && <StepConnectionLoader />}
|
||||
const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
|
||||
const { nextStep, isPageLoading, user } = props;
|
||||
const { t } = useLocale();
|
||||
const [hasAnyInstalledVideoApps, setAnyInstalledVideoApps] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<ConnectedVideoStepInner setAnyInstalledVideoApps={setAnyInstalledVideoApps} t={t} user={user} />
|
||||
<Button
|
||||
EndIcon="arrow-right"
|
||||
data-testid="save-video-button"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { md } from "@calcom/lib/markdownIt";
|
||||
import { telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { UserAvatar } from "@calcom/ui/components/avatar";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Editor } from "@calcom/ui/components/editor";
|
||||
@@ -21,8 +22,11 @@ type FormData = {
|
||||
bio: string;
|
||||
};
|
||||
|
||||
const UserProfile = () => {
|
||||
const [user] = trpc.viewer.me.get.useSuspenseQuery();
|
||||
interface UserProfileProps {
|
||||
user: RouterOutputs["viewer"]["me"]["get"];
|
||||
}
|
||||
|
||||
const UserProfile = ({ user }: UserProfileProps) => {
|
||||
const { t } = useLocale();
|
||||
const avatarRef = useRef<HTMLInputElement>(null);
|
||||
const { setValue, handleSubmit, getValues } = useForm<FormData>({
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import { telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Input } from "@calcom/ui/components/form";
|
||||
|
||||
@@ -21,11 +22,11 @@ import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
|
||||
interface IUserSettingsProps {
|
||||
nextStep: () => void;
|
||||
hideUsername?: boolean;
|
||||
user: RouterOutputs["viewer"]["me"]["get"];
|
||||
}
|
||||
|
||||
const UserSettings = (props: IUserSettingsProps) => {
|
||||
const { nextStep } = props;
|
||||
const [user] = trpc.viewer.me.get.useSuspenseQuery();
|
||||
const { nextStep, user } = props;
|
||||
const { t } = useLocale();
|
||||
const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
@@ -162,20 +162,20 @@ const OnboardingPage = (props: PageProps) => {
|
||||
<StepCard>
|
||||
<Suspense fallback={<Icon name="loader" />}>
|
||||
{currentStep === "user-settings" && (
|
||||
<UserSettings nextStep={goToNextStep} hideUsername={from === "signup"} />
|
||||
<UserSettings nextStep={goToNextStep} hideUsername={from === "signup"} user={user} />
|
||||
)}
|
||||
{currentStep === "connected-calendar" && (
|
||||
<ConnectedCalendars nextStep={goToNextStep} isPageLoading={isNextStepLoading} />
|
||||
)}
|
||||
|
||||
{currentStep === "connected-video" && (
|
||||
<ConnectedVideoStep nextStep={goToNextStep} isPageLoading={isNextStepLoading} />
|
||||
<ConnectedVideoStep nextStep={goToNextStep} isPageLoading={isNextStepLoading} user={user} />
|
||||
)}
|
||||
|
||||
{currentStep === "setup-availability" && (
|
||||
<SetupAvailability nextStep={goToNextStep} defaultScheduleId={user.defaultScheduleId} />
|
||||
)}
|
||||
{currentStep === "user-profile" && <UserProfile />}
|
||||
{currentStep === "user-profile" && <UserProfile user={user} />}
|
||||
</Suspense>
|
||||
</StepCard>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.5.16",
|
||||
"version": "5.5.17",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
+1009
-3570
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,7 @@ Below is a list of props that can be passed to the booker atom.
|
||||
| timeZones | No | Array of valid IANA timezones to be used in the booker. Eg. ["Asia/Kolkata", "Europe/London"] |
|
||||
| onTimeslotsLoaded | No | Callback function triggered once the available timeslots have been fetched. |
|
||||
| roundRobinHideOrgAndTeam | No | Boolean indicating if the organization and team should be hidden in the booker atom sidebar for round robin scheduling type, defaults to false. |
|
||||
| showNoAvailabilityDialog | No | Boolean indicating if the no availability dialog should be shown, defaults to true. |
|
||||
|
||||
## Styling
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ const BookerComponent = ({
|
||||
timeZones,
|
||||
eventMetaChildren,
|
||||
roundRobinHideOrgAndTeam,
|
||||
showNoAvailabilityDialog,
|
||||
}: BookerProps & WrappedBookerProps) => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
|
||||
@@ -417,6 +418,7 @@ const BookerComponent = ({
|
||||
slots={schedule?.data?.slots}
|
||||
isLoading={schedule.isPending}
|
||||
scrollToTimeSlots={scrollToTimeSlots}
|
||||
showNoAvailabilityDialog={showNoAvailabilityDialog}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -445,6 +447,7 @@ const BookerComponent = ({
|
||||
slots={schedule?.data?.slots}
|
||||
isLoading={schedule.isPending}
|
||||
scrollToTimeSlots={scrollToTimeSlots}
|
||||
showNoAvailabilityDialog={showNoAvailabilityDialog}
|
||||
/>
|
||||
</BookerSection>
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export const DatePicker = ({
|
||||
isLoading,
|
||||
classNames,
|
||||
scrollToTimeSlots,
|
||||
showNoAvailabilityDialog,
|
||||
}: {
|
||||
event: {
|
||||
data?: {
|
||||
@@ -72,6 +73,7 @@ export const DatePicker = ({
|
||||
isLoading?: boolean;
|
||||
classNames?: DatePickerClassNames;
|
||||
scrollToTimeSlots?: () => void;
|
||||
showNoAvailabilityDialog?: boolean;
|
||||
}) => {
|
||||
const { i18n } = useLocale();
|
||||
const [month, selectedDate, layout] = useBookerStore(
|
||||
@@ -148,6 +150,7 @@ export const DatePicker = ({
|
||||
scrollToTimeSlots={scrollToTimeSlots}
|
||||
periodData={periodData}
|
||||
isCompact={isCompact}
|
||||
showNoAvailabilityDialog={showNoAvailabilityDialog}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface BookerProps {
|
||||
hashedLink?: string | null;
|
||||
isInstantMeeting?: boolean;
|
||||
teamMemberEmail?: string | null;
|
||||
showNoAvailabilityDialog?: boolean;
|
||||
crmOwnerRecordType?: string | null;
|
||||
crmAppSlug?: string | null;
|
||||
crmRecordId?: string | null;
|
||||
|
||||
@@ -5,9 +5,9 @@ import { SystemField } from "@calcom/features/bookings/lib/SystemField";
|
||||
import type { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
|
||||
import { contructEmailFromPhoneNumber } from "@calcom/lib/contructEmailFromPhoneNumber";
|
||||
import { getBookingWithResponses } from "@calcom/lib/getBooking";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
|
||||
export const getCalEventResponses = ({
|
||||
bookingFields,
|
||||
|
||||
@@ -59,6 +59,8 @@ export type DatePickerProps = {
|
||||
periodData?: PeriodData;
|
||||
// Whether this is a compact sidebar view or main monthly view
|
||||
isCompact?: boolean;
|
||||
// Whether to show the no availability dialog
|
||||
showNoAvailabilityDialog?: boolean;
|
||||
};
|
||||
|
||||
const Day = ({
|
||||
@@ -164,6 +166,7 @@ const Days = ({
|
||||
isBookingInPast,
|
||||
periodData,
|
||||
isCompact,
|
||||
showNoAvailabilityDialog = true,
|
||||
...props
|
||||
}: Omit<DatePickerProps, "locale" | "className" | "weekStart"> & {
|
||||
DayComponent?: React.FC<React.ComponentProps<typeof Day>>;
|
||||
@@ -345,14 +348,18 @@ const Days = ({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!props.isLoading && !isBookingInPast && includedDates && includedDates?.length === 0 && (
|
||||
<NoAvailabilityDialog
|
||||
month={month}
|
||||
nextMonthButton={nextMonthButton}
|
||||
browsingDate={browsingDate}
|
||||
periodData={periodData}
|
||||
/>
|
||||
)}
|
||||
{!props.isLoading &&
|
||||
!isBookingInPast &&
|
||||
includedDates &&
|
||||
includedDates?.length === 0 &&
|
||||
showNoAvailabilityDialog && (
|
||||
<NoAvailabilityDialog
|
||||
month={month}
|
||||
nextMonthButton={nextMonthButton}
|
||||
browsingDate={browsingDate}
|
||||
periodData={periodData}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -374,6 +381,7 @@ const DatePicker = ({
|
||||
periodType: "UNLIMITED",
|
||||
},
|
||||
isCompact,
|
||||
showNoAvailabilityDialog,
|
||||
...passThroughProps
|
||||
}: DatePickerProps &
|
||||
Partial<React.ComponentProps<typeof Days>> & {
|
||||
@@ -484,6 +492,7 @@ const DatePicker = ({
|
||||
isBookingInPast={isBookingInPast}
|
||||
periodData={periodData}
|
||||
isCompact={isCompact}
|
||||
showNoAvailabilityDialog={showNoAvailabilityDialog}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const TeamEventTypeForm = ({
|
||||
|
||||
return (
|
||||
<Form form={form} handleSubmit={handleSubmit}>
|
||||
<div className="mt-3 space-y-6 pb-11">
|
||||
<div className="mt-1 space-y-6">
|
||||
<TextField
|
||||
type="hidden"
|
||||
labelProps={{ style: { display: "none" } }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, Suspense } from "react";
|
||||
import { useState, Suspense, useMemo } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
SelectedCalendarSettingsScope,
|
||||
SelectedCalendarsSettingsWebWrapperSkeleton,
|
||||
} from "@calcom/atoms/selected-calendars/wrappers/SelectedCalendarsSettingsWebWrapper";
|
||||
import { Timezone as PlatformTimzoneSelect } from "@calcom/atoms/timezone";
|
||||
import getLocationsOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import { TimezoneSelect } from "@calcom/features/components/timezone-select";
|
||||
import { TimezoneSelect as WebTimezoneSelect } from "@calcom/features/components/timezone-select";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import {
|
||||
allowDisablingAttendeeConfirmationEmails,
|
||||
@@ -565,6 +566,10 @@ export const EventAdvancedTab = ({
|
||||
userEmail = removePlatformClientIdFromEmail(userEmail, platformContext.clientId);
|
||||
}
|
||||
|
||||
const TimezoneSelect = useMemo(() => {
|
||||
return isPlatform ? PlatformTimzoneSelect : WebTimezoneSelect;
|
||||
}, [isPlatform]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<calendarComponents.CalendarSettings
|
||||
|
||||
@@ -327,7 +327,9 @@ export default class EventManager {
|
||||
}
|
||||
|
||||
const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
|
||||
const isMSTeamsWithOutlookCalendar = evt.location === MSTeamsLocationType && mainHostDestinationCalendar?.integration === "office365_calendar";
|
||||
const isMSTeamsWithOutlookCalendar =
|
||||
evt.location === MSTeamsLocationType &&
|
||||
mainHostDestinationCalendar?.integration === "office365_calendar";
|
||||
|
||||
const results: Array<EventResult<Exclude<Event, AdditionalInformation>>> = [];
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export async function loadTranslations(_locale: string, _ns: string) {
|
||||
const response = await fetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
cache: process.env.NODE_ENV === "production" ? "force-cache" : "no-store",
|
||||
cache: "no-store",
|
||||
},
|
||||
process.env.NODE_ENV === "development" ? 30000 : 3000
|
||||
);
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
## 1.1.2
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#23060](https://github.com/calcom/cal.com/pull/23060) [`293dd1a`](https://github.com/calcom/cal.com/commit/293dd1a1402fbc0748dffd5619a1b1aacaea9d60) Thanks [@SomayChauhan](https://github.com/SomayChauhan)! - fix: EventTypeSettings Atom crashes on enabling `Lock timezone on booking` in advanced tab
|
||||
|
||||
- [#22911](https://github.com/calcom/cal.com/pull/22911) [`d3fbc73`](https://github.com/calcom/cal.com/commit/d3fbc73a3962a9bcca6a95870b146a97f4c7d996) Thanks [@SomayChauhan](https://github.com/SomayChauhan)! - Added new callback functions to the handleFormSubmit method in the EventTypeSettings and AvailabilitySettings atoms. The handleFormSubmit method now accepts an optional callbacks object with the following properties:
|
||||
|
||||
- **onSuccess**: Called when the form submission is successful, allowing additional logic to be executed after the update.
|
||||
|
||||
- **onError**: Called when an error occurs during form submission, providing details about the error to handle specific cases or display custom messages.
|
||||
|
||||
- [#23052](https://github.com/calcom/cal.com/pull/23052) [`fc2e81e`](https://github.com/calcom/cal.com/commit/fc2e81e89a6e320c7346dad3259dd51ed700fb30) Thanks [@SomayChauhan](https://github.com/SomayChauhan)! - Added showNoAvailabilityDialog prop to the Booker atom – a Boolean that controls whether the 'no availability' dialog is displayed
|
||||
|
||||
- [#22976](https://github.com/calcom/cal.com/pull/22976) [`abe92cd`](https://github.com/calcom/cal.com/commit/abe92cd693f33d20113f5357a960baec042e5256) Thanks [@supalarry](https://github.com/supalarry)! - feat: style calendar settings and availability overrides
|
||||
|
||||
- [#22956](https://github.com/calcom/cal.com/pull/22956) [`d18e233`](https://github.com/calcom/cal.com/commit/d18e23378b699e734e9dbbdf93225b02a9059ff4) Thanks [@supalarry](https://github.com/supalarry)! - booker atom: allow toggling org and team info when booking round robin
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -58,6 +58,7 @@ export const BookerPlatformWrapper = (
|
||||
handleSlotReservation,
|
||||
onTimeslotsLoaded,
|
||||
startTime: customStartTime,
|
||||
showNoAvailabilityDialog,
|
||||
} = props;
|
||||
const layout = BookerLayouts[view];
|
||||
|
||||
@@ -465,6 +466,7 @@ export const BookerPlatformWrapper = (
|
||||
customClassNames={props.customClassNames}
|
||||
eventSlug={props.eventSlug}
|
||||
username={username}
|
||||
showNoAvailabilityDialog={showNoAvailabilityDialog}
|
||||
entity={
|
||||
event?.data?.entity ?? {
|
||||
considerUnpublished: false,
|
||||
|
||||
@@ -150,7 +150,7 @@ const EventType = forwardRef<
|
||||
const description = message ? t(message) : t(err.message);
|
||||
toast({ description });
|
||||
onError?.(currentValues, err);
|
||||
|
||||
|
||||
const errorObj = new Error(description);
|
||||
callbacksRef.current?.onError?.(errorObj);
|
||||
},
|
||||
@@ -175,24 +175,27 @@ const EventType = forwardRef<
|
||||
|
||||
const callbacksRef = useRef<{ onSuccess?: () => void; onError?: (error: Error) => void }>({});
|
||||
|
||||
const handleFormSubmit = useCallback((customCallbacks?: { onSuccess?: () => void; onError?: (error: Error) => void }) => {
|
||||
if (customCallbacks) {
|
||||
callbacksRef.current = customCallbacks;
|
||||
}
|
||||
const handleFormSubmit = useCallback(
|
||||
(customCallbacks?: { onSuccess?: () => void; onError?: (error: Error) => void }) => {
|
||||
if (customCallbacks) {
|
||||
callbacksRef.current = customCallbacks;
|
||||
}
|
||||
|
||||
if (saveButtonRef.current) {
|
||||
saveButtonRef.current.click();
|
||||
} else {
|
||||
form.handleSubmit((data) => {
|
||||
try {
|
||||
handleSubmit(data);
|
||||
customCallbacks?.onSuccess?.();
|
||||
} catch (error) {
|
||||
customCallbacks?.onError?.(error as Error);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [handleSubmit, form]);
|
||||
if (saveButtonRef.current) {
|
||||
saveButtonRef.current.click();
|
||||
} else {
|
||||
form.handleSubmit((data) => {
|
||||
try {
|
||||
handleSubmit(data);
|
||||
customCallbacks?.onSuccess?.();
|
||||
} catch (error) {
|
||||
customCallbacks?.onError?.(error as Error);
|
||||
}
|
||||
})();
|
||||
}
|
||||
},
|
||||
[handleSubmit, form]
|
||||
);
|
||||
|
||||
const validateForm = useCallback(async () => {
|
||||
const isValid = await form.trigger();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"description": "Customizable UI components to integrate scheduling into your product.",
|
||||
"authors": "Cal.com, Inc.",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"scripts": {
|
||||
"dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch",
|
||||
"build": "NODE_OPTIONS='--max_old_space_size=12288' rm -rf dist && yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify && mkdir ./dist/packages/prisma-client && cp -rf ../../../node_modules/.prisma/client/*.d.ts ./dist/packages/prisma-client",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const DEFAULT_EXPECT_TIMEOUT = process.env.CI ? 30000 : 120000;
|
||||
const DEFAULT_TEST_TIMEOUT = process.env.CI ? 60000 : 240000;
|
||||
@@ -12,35 +12,35 @@ export default defineConfig({
|
||||
timeout: DEFAULT_TEST_TIMEOUT,
|
||||
fullyParallel: true,
|
||||
reporter: [
|
||||
['list'],
|
||||
['html', { outputFolder: './test-results/reports/playwright-html-report', open: 'never' }],
|
||||
["list"],
|
||||
["html", { outputFolder: "./test-results/reports/playwright-html-report", open: "never" }],
|
||||
],
|
||||
outputDir: './test-results/results',
|
||||
outputDir: "./test-results/results",
|
||||
use: {
|
||||
baseURL: 'http://localhost:4322',
|
||||
locale: 'en-US',
|
||||
trace: 'retain-on-failure',
|
||||
baseURL: "http://localhost:4322",
|
||||
locale: "en-US",
|
||||
trace: "retain-on-failure",
|
||||
headless,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: '@calcom/base',
|
||||
testDir: './tests',
|
||||
name: "@calcom/base",
|
||||
testDir: "./tests",
|
||||
testMatch: /.*\.e2e\.tsx?/,
|
||||
expect: {
|
||||
timeout: DEFAULT_EXPECT_TIMEOUT,
|
||||
},
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
locale: 'en-US',
|
||||
...devices["Desktop Chrome"],
|
||||
locale: "en-US",
|
||||
},
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: process.env.CI
|
||||
command: process.env.CI
|
||||
? `yarn workspace @calcom/atoms dev-on && yarn workspace @calcom/atoms build && rm -f prisma/dev.db && yarn prisma db push && NEXT_PUBLIC_IS_E2E=1 NODE_ENV=test NEXT_PUBLIC_X_CAL_ID="${process.env.ATOMS_E2E_OAUTH_CLIENT_ID}" X_CAL_SECRET_KEY="${process.env.ATOMS_E2E_OAUTH_CLIENT_SECRET}" NEXT_PUBLIC_CALCOM_API_URL="${process.env.ATOMS_E2E_API_URL}" VITE_BOOKER_EMBED_OAUTH_CLIENT_ID="${process.env.ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED}" VITE_BOOKER_EMBED_API_URL="${process.env.ATOMS_E2E_API_URL}" ORGANIZATION_ID=${process.env.ATOMS_E2E_ORG_ID} yarn dev:e2e`
|
||||
: `rm -f prisma/dev.db && yarn prisma db push && yarn dev:e2e`,
|
||||
url: 'http://localhost:4322',
|
||||
url: "http://localhost:4322",
|
||||
timeout: 600_000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
|
||||
+10
-10
@@ -1,19 +1,19 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test('create event type using CreateEventTypeAtom', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
test("create event type using CreateEventTypeAtom", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.goto('/event-types');
|
||||
await page.goto("/event-types");
|
||||
|
||||
await expect(page).toHaveURL('/event-types');
|
||||
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
await expect(page).toHaveURL("/event-types");
|
||||
|
||||
await page.fill('[data-testid="event-type-quick-chat"]', 'e2e event');
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
|
||||
await page.fill('textarea[placeholder="A quick video meeting."]', 'This is an e2e test event description');
|
||||
await page.fill('[data-testid="event-type-quick-chat"]', "e2e event");
|
||||
|
||||
await page.waitForSelector('button[type="submit"]:has-text("Continue")', { state: 'visible' });
|
||||
await page.fill('textarea[placeholder="A quick video meeting."]', "This is an e2e test event description");
|
||||
|
||||
await page.waitForSelector('button[type="submit"]:has-text("Continue")', { state: "visible" });
|
||||
await page.click('button[type="submit"]:has-text("Continue")');
|
||||
|
||||
await expect(page.locator('h1:has-text("E2e Event")')).toBeVisible();
|
||||
|
||||
@@ -128,5 +128,3 @@ export class DeletePrivateLinkOutput {
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -95,21 +95,18 @@ export const createTeamsHandler = async ({ ctx, input }: CreateTeamsOptions) =>
|
||||
teamNames.map((item) => slugify(item)).includes(slug)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
moveTeams
|
||||
.filter((team) => team.shouldMove)
|
||||
.map(async ({ id: teamId, newSlug }) => {
|
||||
await moveTeam({
|
||||
teamId,
|
||||
newSlug,
|
||||
org: {
|
||||
...organization,
|
||||
ownerId: organizationOwner.id,
|
||||
},
|
||||
creationSource,
|
||||
});
|
||||
})
|
||||
);
|
||||
// Process team migrations sequentially to avoid race conditions - Moving a team invites members to the organization again and there are known unique constraints failure in membership and profile creation if done in parallel and a user happens to be part of more than one team
|
||||
for (const team of moveTeams.filter((team) => team.shouldMove)) {
|
||||
await moveTeam({
|
||||
teamId: team.id,
|
||||
newSlug: team.newSlug,
|
||||
org: {
|
||||
...organization,
|
||||
ownerId: organizationOwner.id,
|
||||
},
|
||||
creationSource,
|
||||
});
|
||||
}
|
||||
|
||||
if (duplicatedSlugs.length === teamNames.length) {
|
||||
return { duplicatedSlugs };
|
||||
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { Team, User, Membership, Profile } from "@calcom/prisma/client";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import inviteMemberHandler, { inviteMembersWithNoInviterPermissionCheck } from "./inviteMember.handler";
|
||||
|
||||
// Helper functions for database verification
|
||||
async function verifyProfileExists(userId: number, organizationId: number): Promise<Profile | null> {
|
||||
return await prisma.profile.findUnique({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId,
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyMembershipExists(userId: number, teamId: number): Promise<Membership | null> {
|
||||
return await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyUserOrganizationConsistency(userId: number): Promise<{
|
||||
profileCount: number;
|
||||
acceptedMembershipCount: number;
|
||||
pendingMembershipCount: number;
|
||||
}> {
|
||||
const profiles = await prisma.profile.count({ where: { userId } });
|
||||
const acceptedMemberships = await prisma.membership.count({
|
||||
where: { userId, accepted: true },
|
||||
});
|
||||
const pendingMemberships = await prisma.membership.count({
|
||||
where: { userId, accepted: false },
|
||||
});
|
||||
|
||||
return {
|
||||
profileCount: profiles,
|
||||
acceptedMembershipCount: acceptedMemberships,
|
||||
pendingMembershipCount: pendingMemberships,
|
||||
};
|
||||
}
|
||||
|
||||
// Test data creation helpers with unique identifiers
|
||||
function generateUniqueId() {
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
}
|
||||
|
||||
async function createTestUser(data: { email: string; username: string; name?: string }): Promise<User> {
|
||||
const uniqueId = generateUniqueId();
|
||||
const uniqueEmail = data.email.includes("@")
|
||||
? data.email.replace("@", `-${uniqueId}@`)
|
||||
: `${data.email}-${uniqueId}@example.com`;
|
||||
const uniqueUsername = `${data.username}-${uniqueId}`;
|
||||
|
||||
// Pre-emptive cleanup to avoid conflicts
|
||||
try {
|
||||
await prisma.user.deleteMany({
|
||||
where: { OR: [{ email: uniqueEmail }, { username: uniqueUsername }] },
|
||||
});
|
||||
} catch {}
|
||||
|
||||
return await prisma.user.create({
|
||||
data: {
|
||||
email: uniqueEmail,
|
||||
username: uniqueUsername,
|
||||
name: data.name || uniqueUsername,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createTestTeam(data: {
|
||||
name: string;
|
||||
slug: string;
|
||||
isOrganization?: boolean;
|
||||
parentId?: number;
|
||||
metadata?: any;
|
||||
organizationSettings?: {
|
||||
orgAutoAcceptEmail: string;
|
||||
isOrganizationVerified?: boolean;
|
||||
};
|
||||
}): Promise<Team> {
|
||||
const uniqueId = generateUniqueId();
|
||||
const uniqueSlug = `${data.slug}-${uniqueId}`;
|
||||
|
||||
// Pre-emptive cleanup to avoid conflicts
|
||||
try {
|
||||
await prisma.team.deleteMany({
|
||||
where: { slug: uniqueSlug },
|
||||
});
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
const team = await prisma.team.create({
|
||||
data: {
|
||||
name: `${data.name} ${uniqueId}`,
|
||||
slug: uniqueSlug,
|
||||
isOrganization: data.isOrganization || false,
|
||||
parentId: data.parentId,
|
||||
metadata: data.metadata,
|
||||
},
|
||||
});
|
||||
|
||||
// If this is an organization and organizationSettings are provided, create them
|
||||
if (data.isOrganization && data.organizationSettings) {
|
||||
await prisma.organizationSettings.create({
|
||||
data: {
|
||||
organizationId: team.id,
|
||||
orgAutoAcceptEmail: data.organizationSettings.orgAutoAcceptEmail,
|
||||
isOrganizationVerified: data.organizationSettings.isOrganizationVerified,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return team;
|
||||
}
|
||||
|
||||
// Mock translations fetch
|
||||
vi.mock("node-fetch", () => ({
|
||||
default: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock email sending
|
||||
vi.mock("@calcom/emails", () => ({
|
||||
sendTeamInviteEmail: vi.fn(() => Promise.resolve()),
|
||||
sendOrganizationAutoJoinEmail: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
// Mock for getTranslation
|
||||
vi.mock("@calcom/lib/server/i18n", () => ({
|
||||
getTranslation: vi.fn(() => Promise.resolve((key: string) => key)),
|
||||
}));
|
||||
|
||||
describe("inviteMember.handler Integration Tests", () => {
|
||||
// Track created test data for cleanup
|
||||
let testUsers: User[] = [];
|
||||
let testTeams: Team[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset tracking arrays
|
||||
testUsers = [];
|
||||
testTeams = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test data in reverse dependency order
|
||||
const userIds = testUsers.map((u) => u.id);
|
||||
const teamIds = testTeams.map((t) => t.id);
|
||||
|
||||
if (userIds.length > 0 || teamIds.length > 0) {
|
||||
await prisma.$transaction([
|
||||
// Clean verification tokens for test users
|
||||
prisma.verificationToken.deleteMany({
|
||||
where: { identifier: { in: testUsers.map((u) => u.email) } },
|
||||
}),
|
||||
// Clean memberships
|
||||
prisma.membership.deleteMany({
|
||||
where: {
|
||||
OR: [{ userId: { in: userIds } }, { teamId: { in: teamIds } }],
|
||||
},
|
||||
}),
|
||||
// Clean profiles
|
||||
prisma.profile.deleteMany({
|
||||
where: {
|
||||
OR: [{ userId: { in: userIds } }, { organizationId: { in: teamIds } }],
|
||||
},
|
||||
}),
|
||||
// Clean teams
|
||||
prisma.team.deleteMany({
|
||||
where: { id: { in: teamIds } },
|
||||
}),
|
||||
// Clean users
|
||||
prisma.user.deleteMany({
|
||||
where: { id: { in: userIds } },
|
||||
}),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to track created entities
|
||||
function trackUser(user: User): User {
|
||||
testUsers.push(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
function trackTeam(team: Team): Team {
|
||||
testTeams.push(team);
|
||||
return team;
|
||||
}
|
||||
|
||||
// Helper to create a proper user context for inviteMemberHandler
|
||||
function createUserContext(user: User, organizationId: number | null = null): NonNullable<TrpcSessionUser> {
|
||||
const baseUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username || "",
|
||||
name: user.name,
|
||||
emailVerified: user.emailVerified,
|
||||
avatarUrl: user.avatarUrl,
|
||||
avatar: `https://example.com/avatar.png`,
|
||||
locale: user.locale || "en",
|
||||
timeZone: user.timeZone || "UTC",
|
||||
role: user.role,
|
||||
allowDynamicBooking: user.allowDynamicBooking || true,
|
||||
completedOnboarding: user.completedOnboarding,
|
||||
twoFactorEnabled: user.twoFactorEnabled,
|
||||
identityProvider: user.identityProvider,
|
||||
brandColor: user.brandColor,
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
theme: user.theme,
|
||||
createdDate: user.createdDate,
|
||||
disableImpersonation: user.disableImpersonation,
|
||||
movedToProfileId: user.movedToProfileId,
|
||||
organizationId,
|
||||
organization: organizationId
|
||||
? {
|
||||
id: organizationId,
|
||||
isOrgAdmin: true,
|
||||
metadata: {},
|
||||
requestedSlug: null,
|
||||
}
|
||||
: {
|
||||
id: null,
|
||||
isOrgAdmin: false,
|
||||
metadata: {},
|
||||
requestedSlug: null,
|
||||
},
|
||||
defaultBookerLayouts: null,
|
||||
profile: organizationId
|
||||
? {
|
||||
id: user.id,
|
||||
upId: `${user.username}-${organizationId}`,
|
||||
username: user.username || "",
|
||||
userId: user.id,
|
||||
organizationId,
|
||||
organization: {
|
||||
id: organizationId,
|
||||
name: "Test Organization",
|
||||
slug: "test-org",
|
||||
isOrganization: true,
|
||||
metadata: {},
|
||||
},
|
||||
}
|
||||
: {
|
||||
id: user.id,
|
||||
upId: `${user.username}-default`,
|
||||
username: user.username || "",
|
||||
userId: user.id,
|
||||
organizationId: null,
|
||||
organization: null,
|
||||
},
|
||||
};
|
||||
|
||||
return baseUser as unknown as NonNullable<TrpcSessionUser>;
|
||||
}
|
||||
|
||||
describe("Organization Direct Invite Flow", () => {
|
||||
it("should not auto-accept user's membership that was unaccepted when migrating to org with non-matching autoAcceptEmailDomain", async () => {
|
||||
const organization = trackTeam(
|
||||
await createTestTeam({
|
||||
name: "Test Organization",
|
||||
slug: "test-org",
|
||||
isOrganization: true,
|
||||
metadata: {},
|
||||
organizationSettings: {
|
||||
orgAutoAcceptEmail: "company.com",
|
||||
isOrganizationVerified: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const team = trackTeam(
|
||||
await createTestTeam({
|
||||
name: "Test Team",
|
||||
slug: "test-team",
|
||||
isOrganization: false,
|
||||
parentId: organization.id,
|
||||
})
|
||||
);
|
||||
|
||||
const inviterUser = trackUser(
|
||||
await createTestUser({
|
||||
email: "inviter@company.com",
|
||||
username: "inviter",
|
||||
})
|
||||
);
|
||||
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
userId: inviterUser.id,
|
||||
teamId: organization.id,
|
||||
role: MembershipRole.OWNER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Act: Invite user with non-matching domain
|
||||
const nonMatchingUser = trackUser(
|
||||
await createTestUser({
|
||||
email: "external@other.com",
|
||||
username: "external",
|
||||
})
|
||||
);
|
||||
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
userId: nonMatchingUser.id,
|
||||
teamId: team.id,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: false,
|
||||
},
|
||||
});
|
||||
|
||||
await inviteMemberHandler({
|
||||
ctx: {
|
||||
user: createUserContext(inviterUser, organization.id),
|
||||
session: {} as any,
|
||||
} as any,
|
||||
input: {
|
||||
teamId: organization.id,
|
||||
usernameOrEmail: nonMatchingUser.email,
|
||||
role: MembershipRole.MEMBER,
|
||||
language: "en",
|
||||
creationSource: "WEBAPP" as const,
|
||||
},
|
||||
});
|
||||
|
||||
// Assert: No profile should be created
|
||||
const profile = await verifyProfileExists(nonMatchingUser.id, organization.id);
|
||||
expect(profile).toBeFalsy();
|
||||
|
||||
// Membership should not be auto-accepted
|
||||
const membership = await verifyMembershipExists(nonMatchingUser.id, organization.id);
|
||||
expect(membership).toBeTruthy();
|
||||
expect(membership?.accepted).toBe(false);
|
||||
|
||||
// Verify that the user is not a member of the organization
|
||||
const nonMatchingUserMembershipWithTeam = await verifyMembershipExists(nonMatchingUser.id, team.id);
|
||||
expect(nonMatchingUserMembershipWithTeam).toBeTruthy();
|
||||
expect(nonMatchingUserMembershipWithTeam?.accepted).toBe(false);
|
||||
});
|
||||
|
||||
it("should auto-accept user's membership that was unaccepted when migrating to org with matching autoAcceptEmailDomain", async () => {
|
||||
// Setup: User with unverified email who has an unaccepted team membership
|
||||
const unverifiedUserWithUnacceptedMembership = trackUser(
|
||||
await createTestUser({
|
||||
email: "john.doe@acme.com",
|
||||
username: "john.doe",
|
||||
})
|
||||
);
|
||||
|
||||
// Add unverified email status - will need to update in DB
|
||||
await prisma.user.update({
|
||||
where: { id: unverifiedUserWithUnacceptedMembership.id },
|
||||
data: { emailVerified: null },
|
||||
});
|
||||
|
||||
const organization = trackTeam(
|
||||
await createTestTeam({
|
||||
name: "Acme",
|
||||
slug: "acme",
|
||||
isOrganization: true,
|
||||
metadata: {},
|
||||
organizationSettings: {
|
||||
orgAutoAcceptEmail: "acme.com", // Matches user's email domain
|
||||
isOrganizationVerified: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Create a regular team first where user has unaccepted membership
|
||||
const regularTeam = trackTeam(
|
||||
await createTestTeam({
|
||||
name: "Regular Team",
|
||||
slug: "regular-team",
|
||||
isOrganization: false,
|
||||
parentId: organization.id,
|
||||
})
|
||||
);
|
||||
|
||||
// Add unaccepted team membership
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
teamId: regularTeam.id,
|
||||
userId: unverifiedUserWithUnacceptedMembership.id,
|
||||
accepted: false, // KEY: Unaccepted membership
|
||||
role: MembershipRole.MEMBER,
|
||||
},
|
||||
});
|
||||
|
||||
// Act: Simulate migration by inviting the user to the organization
|
||||
await inviteMembersWithNoInviterPermissionCheck({
|
||||
inviterName: null,
|
||||
teamId: organization.id,
|
||||
language: "en",
|
||||
creationSource: "WEBAPP" as const,
|
||||
orgSlug: organization.slug,
|
||||
invitations: [
|
||||
{
|
||||
usernameOrEmail: unverifiedUserWithUnacceptedMembership.email,
|
||||
role: MembershipRole.MEMBER,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const orgMembership = await verifyMembershipExists(
|
||||
unverifiedUserWithUnacceptedMembership.id,
|
||||
organization.id
|
||||
);
|
||||
const profile = await verifyProfileExists(unverifiedUserWithUnacceptedMembership.id, organization.id);
|
||||
|
||||
expect(profile?.userId).toBe(unverifiedUserWithUnacceptedMembership.id);
|
||||
expect(profile?.organizationId).toBe(organization.id);
|
||||
|
||||
// Verify original team membership is accepted now
|
||||
const originalMembership = await verifyMembershipExists(
|
||||
unverifiedUserWithUnacceptedMembership.id,
|
||||
regularTeam.id
|
||||
);
|
||||
expect(originalMembership?.accepted).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* This file is deprecated in favour of inviteMember.handler.integration-test.ts
|
||||
*
|
||||
* It mocks a lot of things that are untested and integration tests make more sense for this handler
|
||||
*/
|
||||
import { scenarios as checkRateLimitAndThrowErrorScenarios } from "../../../../../../../tests/libs/__mocks__/checkRateLimitAndThrowError";
|
||||
import { mock as getTranslationMock } from "../../../../../../../tests/libs/__mocks__/getTranslation";
|
||||
import {
|
||||
@@ -409,7 +414,7 @@ describe("inviteMemberHandler", () => {
|
||||
|
||||
// Call inviteMembersWithNoInviterPermissionCheck directly with isDirectUserAction=false
|
||||
const { inviteMembersWithNoInviterPermissionCheck } = await import("./inviteMember.handler");
|
||||
|
||||
|
||||
const result = await inviteMembersWithNoInviterPermissionCheck({
|
||||
inviterName: loggedInUser.name,
|
||||
teamId: team.id,
|
||||
@@ -487,7 +492,7 @@ describe("inviteMemberHandler", () => {
|
||||
|
||||
// Call inviteMembersWithNoInviterPermissionCheck directly with isDirectUserAction=false
|
||||
const { inviteMembersWithNoInviterPermissionCheck } = await import("./inviteMember.handler");
|
||||
|
||||
|
||||
const result = await inviteMembersWithNoInviterPermissionCheck({
|
||||
inviterName: "Test Inviter",
|
||||
teamId: team.id,
|
||||
@@ -517,6 +522,7 @@ describe("inviteMemberHandler", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("When rate limit exceeded, it should throw error", async () => {
|
||||
const userToBeInvited = buildExistingUser({
|
||||
id: 1,
|
||||
|
||||
@@ -39,6 +39,9 @@ const mockedReturnSuccessCheckPerms = {
|
||||
role: MembershipRole.ADMIN,
|
||||
userId: 1,
|
||||
teamId: 1,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
customRoleId: null,
|
||||
team: {
|
||||
id: 1,
|
||||
name: "Team A",
|
||||
@@ -95,6 +98,11 @@ const mockedRegularTeam: TeamWithParent = {
|
||||
smsLockState: "LOCKED",
|
||||
createdByOAuthClientId: null,
|
||||
smsLockReviewedByAdmin: false,
|
||||
hideTeamProfileLink: false,
|
||||
rrResetInterval: null,
|
||||
rrTimestampBasis: "CREATED_AT",
|
||||
bookingLimits: null,
|
||||
includeManagedEventsInLimits: false,
|
||||
};
|
||||
|
||||
const mockedSubTeam = {
|
||||
@@ -263,9 +271,13 @@ describe("Invite Member Utils", () => {
|
||||
adminGetsNoSlotsNotification: false,
|
||||
isAdminReviewed: false,
|
||||
isAdminAPIEnabled: false,
|
||||
allowSEOIndexing: false,
|
||||
orgProfileRedirectsToVerifiedDomain: false,
|
||||
disablePhoneOnlySMSNotifications: false,
|
||||
},
|
||||
slug: "abc",
|
||||
parent: null,
|
||||
isOrganization: true,
|
||||
};
|
||||
const result = getOrgState(true, { ...mockedRegularTeam, ...team });
|
||||
expect(result).toEqual({
|
||||
@@ -293,6 +305,9 @@ describe("Invite Member Utils", () => {
|
||||
adminGetsNoSlotsNotification: false,
|
||||
isAdminReviewed: false,
|
||||
isAdminAPIEnabled: false,
|
||||
allowSEOIndexing: false,
|
||||
orgProfileRedirectsToVerifiedDomain: false,
|
||||
disablePhoneOnlySMSNotifications: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -410,6 +425,7 @@ describe("Invite Member Utils", () => {
|
||||
...mockedRegularTeam,
|
||||
parentId: null,
|
||||
id: inviteeOrgId,
|
||||
isOrganization: true,
|
||||
};
|
||||
expect(canBeInvited(inviteeWithOrg, organization)).toBe(INVITE_STATUS.USER_ALREADY_INVITED_OR_MEMBER);
|
||||
});
|
||||
@@ -445,12 +461,33 @@ describe("Invite Member Utils", () => {
|
||||
const organization = {
|
||||
...mockedRegularTeam,
|
||||
id: organizationIdBeingInvitedTo,
|
||||
isOrganization: true,
|
||||
};
|
||||
expect(canBeInvited(inviteeWithOrg, organization)).toBe(
|
||||
INVITE_STATUS.USER_MEMBER_OF_OTHER_ORGANIZATION
|
||||
);
|
||||
});
|
||||
|
||||
it("should return CAN_BE_INVITED if the user being invited has a profile with the organization already", () => {
|
||||
const organizationId = 3;
|
||||
const inviteeWithOrg: UserWithMembership = {
|
||||
...invitee,
|
||||
profiles: [
|
||||
getSampleProfile({
|
||||
organizationId: organizationId,
|
||||
}),
|
||||
],
|
||||
teams: [],
|
||||
};
|
||||
|
||||
const organization = {
|
||||
...mockedRegularTeam,
|
||||
id: organizationId,
|
||||
isOrganization: true,
|
||||
};
|
||||
expect(canBeInvited(inviteeWithOrg, organization)).toBe(INVITE_STATUS.CAN_BE_INVITED);
|
||||
});
|
||||
|
||||
it("should return USER_MEMBER_OF_OTHER_ORGANIZATION if the invitee is being invited to a sub-team in an organization but he belongs to another organization", () => {
|
||||
const inviteeOrganizationId = 2;
|
||||
const subTeamOrganizationId = 3;
|
||||
|
||||
@@ -160,10 +160,27 @@ export function canBeInvited(invitee: UserWithMembership, team: TeamWithParent)
|
||||
return INVITE_STATUS.USER_PENDING_MEMBER_OF_THE_ORG;
|
||||
}
|
||||
|
||||
const hasDifferentOrganizationProfile = invitee.profiles.some((profile) => {
|
||||
const isRegularTeam = !team.isOrganization && !team.parentId;
|
||||
if (isRegularTeam) {
|
||||
// ⚠️ Inviting to a regular team but the user has a profile with some organization
|
||||
return true;
|
||||
}
|
||||
|
||||
const isOrganization = team.isOrganization && !team.parentId;
|
||||
if (isOrganization) {
|
||||
// ⚠️ User has profile with different organization than the organization being invited to
|
||||
return profile.organizationId !== team.id;
|
||||
}
|
||||
|
||||
// ⚠️ User having profile with an organization is invited to join a sub-team that is not part of the organization
|
||||
return profile.organizationId != team.parentId;
|
||||
});
|
||||
|
||||
if (
|
||||
!ENABLE_PROFILE_SWITCHER &&
|
||||
// Member of an organization is invited to join a team that is not a subteam of the organization
|
||||
invitee.profiles.find((profile) => profile.organizationId != team.parentId)
|
||||
// User having profile with an organization is invited to join a sub-team that is not part of the organization
|
||||
hasDifferentOrganizationProfile
|
||||
) {
|
||||
return INVITE_STATUS.USER_MEMBER_OF_OTHER_ORGANIZATION;
|
||||
}
|
||||
@@ -828,7 +845,7 @@ export async function handleExistingUsersInvites({
|
||||
if (parentOrganization) {
|
||||
const parsedOrg = getParsedTeam(parentOrganization);
|
||||
// Create profiles if needed
|
||||
await Promise.all([
|
||||
await Promise.all(
|
||||
autoJoinUsers
|
||||
.concat(regularUsers)
|
||||
.filter((u) => u.needToCreateProfile)
|
||||
@@ -841,8 +858,8 @@ export async function handleExistingUsersInvites({
|
||||
},
|
||||
organizationId: parsedOrg.id,
|
||||
})
|
||||
),
|
||||
]);
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const organization = team;
|
||||
@@ -877,6 +894,22 @@ export async function handleExistingUsersInvites({
|
||||
role: user.newRole,
|
||||
},
|
||||
});
|
||||
|
||||
// If auto-accepting into org, also accept any pending sub-team memberships
|
||||
if (shouldAutoAccept) {
|
||||
await prisma.membership.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
accepted: false,
|
||||
team: {
|
||||
parentId: organization.id,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
...user,
|
||||
profile,
|
||||
|
||||
@@ -106,7 +106,7 @@ export const Select = <
|
||||
),
|
||||
indicatorsContainer: (state) =>
|
||||
cx(
|
||||
"flex items-center justify-center mt-0.5",
|
||||
"flex !items-start justify-center mt-0.5 h-full",
|
||||
state.selectProps.menuIsOpen
|
||||
? hasMultiLastIcons
|
||||
? "[&>*:last-child]:rotate-180 [&>*:last-child]:transition-transform [&>*:last-child]:w-4 [&>*:last-child]:h-4"
|
||||
|
||||
Reference in New Issue
Block a user