Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20efb566b2 | ||
|
|
d9ebf26e21 | ||
|
|
cb13874574 | ||
|
|
1fa02d957e | ||
|
|
529ca159f7 | ||
|
|
be12ac75b4 | ||
|
|
68cdfd42b1 | ||
|
|
3ebac0194e | ||
|
|
6bdf320640 | ||
|
|
cdfdce533e | ||
|
|
5e29a7fa2a | ||
|
|
97377c4554 | ||
|
|
acc82f808d | ||
|
|
3a59cd41cc | ||
|
|
6ae2ce8ed9 | ||
|
|
476460dad8 | ||
|
|
19cadd18e3 | ||
|
|
e20cc960f1 | ||
|
|
f350542d0d | ||
|
|
257a49ce88 | ||
|
|
75b93cebdb | ||
|
|
161ebdbbec | ||
|
|
978c5bdb95 |
@@ -0,0 +1,4 @@
|
||||
/.yarn/** linguist-vendored
|
||||
/.yarn/releases/* binary
|
||||
/.yarn/plugins/**/* binary
|
||||
/.pnp.* binary linguist-generated
|
||||
@@ -15,6 +15,7 @@ You are a senior Cal.com engineer working in a Yarn/Turbo monorepo. You prioriti
|
||||
- Add translations to `apps/web/public/static/locales/en/common.json` for all UI strings
|
||||
- Use `date-fns` or native `Date` instead of Day.js when timezone awareness isn't needed
|
||||
- Put permission checks in `page.tsx`, never in `layout.tsx`
|
||||
- Use `ast-grep` for searching if available; otherwise use `rg` (ripgrep), then fall back to `grep`
|
||||
|
||||
## Don't
|
||||
|
||||
|
||||
@@ -836,12 +836,12 @@ following
|
||||
|
||||
1. Open [HubSpot Developer](https://developer.hubspot.com/) and sign into your account, or create a new one.
|
||||
2. From within the home of the Developer account page, go to "Manage apps".
|
||||
3. Click "Create app" button top right.
|
||||
3. Click "Create legacy app" button top right and select public app.
|
||||
4. Fill in any information you want in the "App info" tab
|
||||
5. Go to tab "Auth"
|
||||
6. Now copy the Client ID and Client Secret to your `.env` file into the `HUBSPOT_CLIENT_ID` and `HUBSPOT_CLIENT_SECRET` fields.
|
||||
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/hubspot/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
8. In the "Scopes" section at the bottom of the page, make sure you select "Read" and "Write" for scope called `crm.objects.contacts`
|
||||
8. In the "Scopes" section at the bottom of the page, make sure you select "Read" and "Write" for scopes called `crm.objects.contacts` and `crm.lists`.
|
||||
9. Click the "Save" button at the bottom footer.
|
||||
10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts.
|
||||
|
||||
|
||||
@@ -27,6 +27,12 @@ import { User } from "@prisma/client";
|
||||
|
||||
- Use React children and context instead of passing props through multiple components
|
||||
|
||||
### ORM and Types
|
||||
|
||||
- Never import `@calcom/prisma/client` in features, services, UI, or handlers.
|
||||
- Use repository DTOs or domain types instead.
|
||||
- See "Repository + DTO Pattern and Method Conventions" in the knowledge base for details and examples.
|
||||
|
||||
### Security Rules
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -306,9 +306,54 @@ import { Button } from "@calcom/ui/components/button";
|
||||
- **Tests**: Same as source file + `.test.ts` or `.spec.ts`
|
||||
- **Avoid**: Dot-suffixes like `.service.ts`, `.repository.ts` (except for tests, types, specs)
|
||||
|
||||
## Repository Method Conventions
|
||||
## Repository + DTO Pattern and Method Conventions
|
||||
|
||||
Repositories should follow consistent naming and design patterns to promote reusability and maintainability.
|
||||
We use a Repository + DTO pattern to isolate Prisma and the database from business logic. Repositories are the only layer that talks to Prisma and database models. All services, tRPC handlers, API controllers, workflows, and UI should depend on DTOs or domain types, not Prisma types.
|
||||
|
||||
DTOs (Data Transfer Objects) are simple TypeScript types or interfaces that represent our domain data in an ORM-agnostic way and are returned from repositories.
|
||||
|
||||
### Why this pattern?
|
||||
|
||||
- **Prevent type leaks**: Prisma type changes stay inside repositories instead of breaking services, handlers, and UI across dozens of files.
|
||||
- **Enable safe refactors**: Business logic works against stable DTOs, so you can change storage details without touching higher layers.
|
||||
- **Keep ORM swappable**: If we ever change ORM, only repository implementations change; services and handlers stay the same.
|
||||
|
||||
### What not to do: tight Prisma coupling in business logic
|
||||
|
||||
Do not import Prisma types in business logic. Prisma types belong only in the data access layer.
|
||||
|
||||
```typescript
|
||||
// ❌ Bad – business logic depends on Prisma
|
||||
import type { Webhook } from "@calcom/prisma/client";
|
||||
export function sendPayload(webhook: Webhook) { /* ... */ }
|
||||
|
||||
// ✅ Good – business logic depends on DTO + repository
|
||||
export interface WebhookDTO { id: string; url: string; }
|
||||
export interface IWebhookRepository { findById(id: string): Promise<WebhookDTO | null>; }
|
||||
export async function sendPayload(repo: IWebhookRepository, id: string) {
|
||||
const webhook = await repo.findById(id);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### DTOs in repositories
|
||||
|
||||
Repositories should expose DTOs from their public methods and keep Prisma types internal. Inside the repository, use `Prisma.*GetPayload` and map rows to DTOs. The public methods should only return DTOs, never Prisma models.
|
||||
|
||||
```typescript
|
||||
// DTO – ORM-agnostic type exported from the repository module
|
||||
export interface BookingDTO {
|
||||
id: string;
|
||||
userId: number;
|
||||
startTime: Date;
|
||||
status: "PENDING" | "CONFIRMED" | "CANCELLED";
|
||||
}
|
||||
```
|
||||
|
||||
### Prisma boundaries
|
||||
|
||||
- **Allowed**: `packages/prisma`, repository implementations (`packages/features/**/repositories/*Repository.ts`), and low-level data access infrastructure.
|
||||
- **Not allowed**: `packages/features/**` business logic (non-repository), `packages/trpc/**` handlers, `apps/web/**`, `apps/api/v2/**` services/controllers, and workflow/webhook/service layers.
|
||||
|
||||
### Method Naming Rules
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ export class BookingsController_2024_04_15 {
|
||||
private readonly instantBookingCreateService: InstantBookingCreateService,
|
||||
private readonly eventTypeRepository: PrismaEventTypeRepository,
|
||||
private readonly teamRepository: PrismaTeamRepository
|
||||
) {}
|
||||
) { }
|
||||
|
||||
@Get("/")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@@ -270,6 +270,7 @@ export class BookingsController_2024_04_15 {
|
||||
const res = await handleCancelBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
userUuid: bookingRequest.userUuid,
|
||||
arePlatformEmailsEnabled: bookingRequest.arePlatformEmailsEnabled,
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
@@ -300,7 +301,7 @@ export class BookingsController_2024_04_15 {
|
||||
@Permissions([BOOKING_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async markNoShow(
|
||||
@GetUser("id") userId: number,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() body: MarkNoShowInput_2024_04_15,
|
||||
@Param("bookingUid") bookingUid: string
|
||||
): Promise<MarkNoShowOutput_2024_04_15> {
|
||||
@@ -309,7 +310,8 @@ export class BookingsController_2024_04_15 {
|
||||
bookingUid: bookingUid,
|
||||
attendees: body.attendees,
|
||||
noShowHost: body.noShowHost,
|
||||
userId,
|
||||
userId: user.id,
|
||||
userUuid: user.uuid,
|
||||
});
|
||||
|
||||
return { status: SUCCESS_STATUS, data: markNoShowResponse };
|
||||
@@ -378,7 +380,7 @@ export class BookingsController_2024_04_15 {
|
||||
): Promise<ApiResponse<InstantBookingCreateResult>> {
|
||||
const oAuthClientId =
|
||||
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
|
||||
req.userId = (await this.getOwnerId(req)) ?? -1;
|
||||
req.userId = (await this.getOwner(req))?.id ?? -1;
|
||||
try {
|
||||
const bookingReq = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
||||
const instantMeeting = await this.instantBookingCreateService.createBooking({
|
||||
@@ -405,30 +407,45 @@ export class BookingsController_2024_04_15 {
|
||||
throw new InternalServerErrorException("Could not create instant booking.");
|
||||
}
|
||||
|
||||
private async getOwnerId(req: Request): Promise<number | undefined> {
|
||||
private async getOwner(req: Request): Promise<{ id: number; uuid: string } | null> {
|
||||
try {
|
||||
const bearerToken = req.get("Authorization")?.replace("Bearer ", "");
|
||||
if (bearerToken) {
|
||||
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
|
||||
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
|
||||
const apiKeyHash = sha256Hash(strippedApiKey);
|
||||
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||
return keyData?.userId;
|
||||
} else {
|
||||
// Access Token
|
||||
const ownerId = await this.oAuthFlowService.getOwnerId(bearerToken);
|
||||
return ownerId;
|
||||
}
|
||||
if (!bearerToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let ownerId: number | null = null;
|
||||
|
||||
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
|
||||
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
|
||||
const apiKeyHash = sha256Hash(strippedApiKey);
|
||||
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||
ownerId = keyData?.userId ?? null;
|
||||
} else {
|
||||
// Access Token
|
||||
ownerId = await this.oAuthFlowService.getOwnerId(bearerToken);
|
||||
}
|
||||
|
||||
if (!ownerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findById(ownerId);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { id: user.id, uuid: user.uuid };
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async getOwnerIdRescheduledBooking(
|
||||
private async getOwnerRescheduledBooking(
|
||||
request: Request,
|
||||
platformClientId?: string
|
||||
): Promise<number | undefined> {
|
||||
): Promise<{ id: number; uuid: string } | null> {
|
||||
if (
|
||||
platformClientId &&
|
||||
request.body.rescheduledBy &&
|
||||
@@ -440,13 +457,15 @@ export class BookingsController_2024_04_15 {
|
||||
);
|
||||
}
|
||||
|
||||
if (request.body.rescheduledBy) {
|
||||
if (request.body.rescheduledBy !== request.body.responses.email) {
|
||||
return (await this.usersRepository.findByEmail(request.body.rescheduledBy))?.id;
|
||||
if (request.body.rescheduledBy && request.body.rescheduledBy !== request.body.responses.email) {
|
||||
const user = await this.usersRepository.findByEmail(request.body.rescheduledBy);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
return { id: user.id, uuid: user.uuid };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getOAuthClientIdFromEventType(eventTypeId: number): Promise<string | undefined> {
|
||||
@@ -498,7 +517,8 @@ export class BookingsController_2024_04_15 {
|
||||
}
|
||||
}
|
||||
|
||||
const userId = await this.getOwnerId(req);
|
||||
const owner = await this.getOwner(req);
|
||||
const userId = owner?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException(
|
||||
@@ -561,12 +581,16 @@ export class BookingsController_2024_04_15 {
|
||||
oAuthClientId?: string,
|
||||
platformBookingLocation?: string,
|
||||
isEmbed?: string
|
||||
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
|
||||
): Promise<NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams> {
|
||||
const requestId = req.get("X-Request-Id");
|
||||
const clone = { ...req };
|
||||
const userId = clone.body.rescheduleUid
|
||||
? await this.getOwnerIdRescheduledBooking(req, oAuthClientId)
|
||||
: await this.getOwnerId(req);
|
||||
const owner = clone.body.rescheduleUid
|
||||
? await this.getOwnerRescheduledBooking(req, oAuthClientId)
|
||||
: await this.getOwner(req);
|
||||
|
||||
const userId = owner?.id;
|
||||
const userUuid = owner?.uuid;
|
||||
|
||||
const oAuthParams = oAuthClientId
|
||||
? await this.getOAuthClientsParams(oAuthClientId, this.transformToBoolean(isEmbed))
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
@@ -577,7 +601,7 @@ export class BookingsController_2024_04_15 {
|
||||
oAuthClientId,
|
||||
...oAuthParams,
|
||||
});
|
||||
Object.assign(clone, { userId, ...oAuthParams, platformBookingLocation });
|
||||
Object.assign(clone, { userId, userUuid, ...oAuthParams, platformBookingLocation });
|
||||
clone.body = {
|
||||
...clone.body,
|
||||
noEmail: !oAuthParams.arePlatformEmailsEnabled,
|
||||
@@ -586,7 +610,7 @@ export class BookingsController_2024_04_15 {
|
||||
if (oAuthClientId) {
|
||||
await this.setPlatformAttendeesEmails(clone.body, oAuthClientId);
|
||||
}
|
||||
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||
return clone as unknown as NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams;
|
||||
}
|
||||
|
||||
async setPlatformAttendeesEmails(
|
||||
@@ -612,9 +636,12 @@ export class BookingsController_2024_04_15 {
|
||||
oAuthClientId?: string,
|
||||
platformBookingLocation?: string,
|
||||
isEmbed?: string
|
||||
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
|
||||
): Promise<NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams> {
|
||||
const clone = { ...req };
|
||||
const userId = (await this.getOwnerId(req)) ?? -1;
|
||||
const owner = await this.getOwner(req);
|
||||
const userId = owner?.id ?? -1;
|
||||
const userUuid = owner?.uuid;
|
||||
|
||||
const oAuthParams = oAuthClientId
|
||||
? await this.getOAuthClientsParams(oAuthClientId, this.transformToBoolean(isEmbed))
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
@@ -628,6 +655,7 @@ export class BookingsController_2024_04_15 {
|
||||
});
|
||||
Object.assign(clone, {
|
||||
userId,
|
||||
userUuid,
|
||||
...oAuthParams,
|
||||
platformBookingLocation,
|
||||
noEmail: !oAuthParams.arePlatformEmailsEnabled,
|
||||
@@ -636,7 +664,7 @@ export class BookingsController_2024_04_15 {
|
||||
if (oAuthClientId) {
|
||||
await this.setPlatformAttendeesEmails(clone.body, oAuthClientId);
|
||||
}
|
||||
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
|
||||
return clone as unknown as NextApiRequest & { userId?: number; userUuid?: string } & OAuthRequestParams;
|
||||
}
|
||||
|
||||
private handleBookingErrors(
|
||||
|
||||
@@ -397,18 +397,18 @@ export class BookingsController_2024_08_13 {
|
||||
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
|
||||
`,
|
||||
})
|
||||
async markNoShow(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Body() body: MarkAbsentBookingInput_2024_08_13,
|
||||
@GetUser("id") ownerId: number
|
||||
): Promise<MarkAbsentBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.markAbsent(bookingUid, ownerId, body);
|
||||
async markNoShow(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Body() body: MarkAbsentBookingInput_2024_08_13,
|
||||
@GetUser() user: ApiAuthGuardUser
|
||||
): Promise<MarkAbsentBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.markAbsent(bookingUid, user.id, body, user.uuid);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: booking,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: booking,
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/:bookingUid/reassign")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -919,34 +919,40 @@ export class BookingsService_2024_08_13 {
|
||||
return await this.getBooking(recurringBookingUid, authUser);
|
||||
}
|
||||
|
||||
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
|
||||
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
|
||||
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
|
||||
async markAbsent(
|
||||
bookingUid: string,
|
||||
bookingOwnerId: number,
|
||||
body: MarkAbsentBookingInput_2024_08_13,
|
||||
userUuid?: string
|
||||
) {
|
||||
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
|
||||
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
|
||||
|
||||
if (!bookingBefore) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} not found.`);
|
||||
}
|
||||
if (!bookingBefore) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} not found.`);
|
||||
}
|
||||
|
||||
const nowUtc = DateTime.utc();
|
||||
const bookingStartTimeUtc = DateTime.fromJSDate(bookingBefore.startTime, { zone: "utc" });
|
||||
const nowUtc = DateTime.utc();
|
||||
const bookingStartTimeUtc = DateTime.fromJSDate(bookingBefore.startTime, { zone: "utc" });
|
||||
|
||||
if (nowUtc < bookingStartTimeUtc) {
|
||||
throw new BadRequestException(
|
||||
`Bookings can only be marked as absent after their scheduled start time. Current time in UTC+0: ${nowUtc.toISO()}, Booking start time in UTC+0: ${bookingStartTimeUtc.toISO()}`
|
||||
);
|
||||
}
|
||||
if (nowUtc < bookingStartTimeUtc) {
|
||||
throw new BadRequestException(
|
||||
`Bookings can only be marked as absent after their scheduled start time. Current time in UTC+0: ${nowUtc.toISO()}, Booking start time in UTC+0: ${bookingStartTimeUtc.toISO()}`
|
||||
);
|
||||
}
|
||||
|
||||
const platformClientParams = bookingBefore?.eventTypeId
|
||||
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
|
||||
: undefined;
|
||||
const platformClientParams = bookingBefore?.eventTypeId
|
||||
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
await handleMarkNoShow({
|
||||
bookingUid,
|
||||
attendees: bodyTransformed.attendees,
|
||||
noShowHost: bodyTransformed.noShowHost,
|
||||
userId: bookingOwnerId,
|
||||
platformClientParams,
|
||||
});
|
||||
await handleMarkNoShow({
|
||||
bookingUid,
|
||||
attendees: bodyTransformed.attendees,
|
||||
noShowHost: bodyTransformed.noShowHost,
|
||||
userId: bookingOwnerId,
|
||||
userUuid,
|
||||
platformClientParams,
|
||||
});
|
||||
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ async function handler(req: NextRequest) {
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: piiHasher.hash(ip),
|
||||
identifier: `forgotPassword:${piiHasher.hash(ip)}`,
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -184,8 +184,7 @@ export const AppPage = ({
|
||||
enabled: !!dependencies,
|
||||
});
|
||||
|
||||
const disableInstall =
|
||||
dependencyData.data && dependencyData.data.some((dependency) => !dependency.installed);
|
||||
const disableInstall = dependencyData.data ? dependencyData.data.some((dependency) => !dependency.installed) : false;
|
||||
|
||||
// const disableInstall = requiresGCal && !gCalInstalled.data;
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { InstallAppButtonChild } from "./InstallAppButtonChild";
|
||||
|
||||
// Mock credential type
|
||||
type MockCredential = {
|
||||
id: number;
|
||||
delegatedToId: string;
|
||||
userId: number;
|
||||
user: { email: string };
|
||||
key: { access_token: string };
|
||||
invalid: boolean;
|
||||
teamId: null;
|
||||
team: null;
|
||||
delegationCredentialId: string;
|
||||
type:
|
||||
| `${string}_calendar`
|
||||
| `${string}_messaging`
|
||||
| `${string}_payment`
|
||||
| `${string}_video`
|
||||
| `${string}_other`
|
||||
| `${string}_automation`
|
||||
| `${string}_analytics`
|
||||
| `${string}_crm`
|
||||
| `${string}_other_calendar`;
|
||||
appId: string;
|
||||
};
|
||||
|
||||
// Factory function to create mock credentials
|
||||
const createMockCredential = (overrides: Partial<MockCredential> = {}): MockCredential => ({
|
||||
id: 1,
|
||||
type: "google_calendar" as const,
|
||||
userId: 1,
|
||||
delegatedToId: "delegation-123",
|
||||
teamId: null,
|
||||
team: null,
|
||||
user: { email: "test@example.com" },
|
||||
key: { access_token: "mock_token_123" },
|
||||
delegationCredentialId: "delegation-123",
|
||||
appId: "google-calendar",
|
||||
invalid: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Mock the useLocale hook
|
||||
vi.mock("@calcom/lib/hooks/useLocale", () => ({
|
||||
useLocale: () => ({
|
||||
t: (key: string) => {
|
||||
const translations = {
|
||||
install_app: "Install App",
|
||||
install_another: "Install Another",
|
||||
start_paid_trial: "Start Free Trial",
|
||||
subscribe: "Subscribe",
|
||||
};
|
||||
return translations[key as keyof typeof translations] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("InstallAppButtonChild", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("is disabled when credentials exist and multiInstall is false", () => {
|
||||
const mockCredentials = [createMockCredential()];
|
||||
render(<InstallAppButtonChild multiInstall={false} credentials={mockCredentials} />);
|
||||
|
||||
const button = screen.getByTestId("install-app-button");
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it("is enabled when credentials exist and multiInstall is true", () => {
|
||||
const mockCredentials = [createMockCredential()];
|
||||
render(<InstallAppButtonChild multiInstall={true} credentials={mockCredentials} />);
|
||||
|
||||
const button = screen.getByTestId("install-app-button");
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("is disabled when disabled prop is true regardless of credentials", () => {
|
||||
render(<InstallAppButtonChild disabled={true} multiInstall={true} credentials={[]} />);
|
||||
|
||||
const button = screen.getByTestId("install-app-button");
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it("combines disabled prop with credential logic correctly", () => {
|
||||
const mockCredentials = [createMockCredential()];
|
||||
render(<InstallAppButtonChild disabled={true} multiInstall={false} credentials={mockCredentials} />);
|
||||
|
||||
const button = screen.getByTestId("install-app-button");
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it("is enabled when no blocking conditions exist", () => {
|
||||
render(<InstallAppButtonChild multiInstall={false} credentials={[]} disabled={false} />);
|
||||
|
||||
const button = screen.getByTestId("install-app-button");
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ export const InstallAppButtonChild = ({
|
||||
const { t } = useLocale();
|
||||
|
||||
const shouldDisableInstallation = !multiInstall ? !!(credentials && credentials.length) : false;
|
||||
const isDisabled = shouldDisableInstallation || props.disabled;
|
||||
|
||||
// Paid apps don't support team installs at the moment
|
||||
// Also, cal.ai(the only paid app at the moment) doesn't support team install either
|
||||
@@ -25,7 +26,7 @@ export const InstallAppButtonChild = ({
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
disabled={shouldDisableInstallation}
|
||||
disabled={isDisabled}
|
||||
color="primary"
|
||||
size="base">
|
||||
{paid.trial ? t("start_paid_trial") : t("subscribe")}
|
||||
@@ -37,7 +38,7 @@ export const InstallAppButtonChild = ({
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
disabled={shouldDisableInstallation}
|
||||
disabled={isDisabled}
|
||||
color="primary"
|
||||
size="base">
|
||||
{multiInstall ? t("install_another") : t("install_app")}
|
||||
|
||||
@@ -312,14 +312,12 @@ function CallHistoryContent({ org: _org }: CallHistoryProps) {
|
||||
</>
|
||||
}
|
||||
EmptyView={
|
||||
<div className="px-6 py-8">
|
||||
<EmptyScreen
|
||||
Icon="phone"
|
||||
headline={searchTerm ? t("no_result_found_for", { searchTerm }) : t("no_call_history")}
|
||||
description={t("no_call_history_description")}
|
||||
className="mb-16"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { PERMISSION_MAP } from "@calcom/platform-constants";
|
||||
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants/permissions";
|
||||
@@ -18,10 +17,8 @@ import type { FormValues } from "@components/settings/platform/oauth-clients/oau
|
||||
import { OAuthClientForm } from "@components/settings/platform/oauth-clients/oauth-client-form";
|
||||
|
||||
export default function CreateOAuthClient() {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const clientId = searchParams?.get("clientId") || "";
|
||||
|
||||
const { isUserLoading, isPlatformUser, isPaidUser } = useGetUserAttributes();
|
||||
|
||||
@@ -77,24 +74,7 @@ export default function CreateOAuthClient() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<OAuthClientForm
|
||||
isPending={isSaving}
|
||||
onSubmit={onSubmit}
|
||||
defaultValues={{
|
||||
areCalendarEventsEnabled: true,
|
||||
areEmailsEnabled: true,
|
||||
eventTypeRead: true,
|
||||
eventTypeWrite: true,
|
||||
bookingRead: true,
|
||||
bookingWrite: true,
|
||||
scheduleRead: true,
|
||||
scheduleWrite: true,
|
||||
appsRead: true,
|
||||
appsWrite: true,
|
||||
profileRead: true,
|
||||
profileWrite: true,
|
||||
}}
|
||||
/>
|
||||
<OAuthClientForm isPending={isSaving} onSubmit={onSubmit} />
|
||||
</div>
|
||||
</Shell>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.9.11",
|
||||
"version": "5.9.12",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -124,11 +124,11 @@
|
||||
"posthog-node": "^5.9.5",
|
||||
"qrcode": "^1.5.1",
|
||||
"raw-body": "^2.5.1",
|
||||
"react": "^18.2.0",
|
||||
"react": "18.2.0",
|
||||
"react-colorful": "^5.6.0",
|
||||
"react-date-picker": "^8.3.6",
|
||||
"react-digit-input": "^2.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-easy-crop": "^3.5.2",
|
||||
"react-hook-form": "^7.43.3",
|
||||
"react-live-chat-loader": "^2.8.1",
|
||||
|
||||
@@ -36,7 +36,7 @@ async function handler(req: NextApiRequest & { userId?: number; traceContext: Tr
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: piiHasher.hash(userIp),
|
||||
identifier: `createBooking:${piiHasher.hash(userIp)}`,
|
||||
});
|
||||
|
||||
const session = await getServerSession({ req });
|
||||
|
||||
@@ -38,7 +38,7 @@ async function handler(req: NextApiRequest & RequestMeta) {
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: piiHasher.hash(userIp),
|
||||
identifier: `createRecurringBooking:${piiHasher.hash(userIp)}`,
|
||||
});
|
||||
const session = await getServerSession({ req });
|
||||
/* To mimic API behavior and comply with types */
|
||||
|
||||
@@ -3261,7 +3261,11 @@
|
||||
"filter_operator_is_empty": "Is empty",
|
||||
"filter_operator_not_empty": "Not empty",
|
||||
"translate_description_button": "Auto translate title and description",
|
||||
"translate_description_button_description": "Automatically translate titles and descriptions to the visitor’s browser language using AI.",
|
||||
"translate_description_button_description": "Automatically translate titles and descriptions to the visitor's browser language using AI.",
|
||||
"auto_translate_booking_title": "Auto translate booking title",
|
||||
"auto_translate_booking_title_description": "Automatically translate the booking title to the visitor's browser language. Disable this to keep the original title.",
|
||||
"auto_translate_instant_meeting_title": "Auto translate instant meeting title",
|
||||
"auto_translate_instant_meeting_title_description": "Automatically translate the instant meeting booking title to the visitor's browser language. Disable this to keep the original English title.",
|
||||
"rr_distribution_method": "Distribution",
|
||||
"rr_distribution_method_description": "Allows for optimising distribution for maximum availability or to aim for a more balanced assignment.",
|
||||
"rr_distribution_method_availability_title": "Maximize availability",
|
||||
@@ -4105,5 +4109,6 @@
|
||||
"actor": "Actor",
|
||||
"timestamp": "Timestamp",
|
||||
"json": "JSON",
|
||||
"hubspot_ignore_guests": "Do not create new records for guests added to the booking",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
autoTranslateDescriptionEnabled,
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
...evType
|
||||
@@ -161,6 +162,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
autoTranslateDescriptionEnabled,
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
};
|
||||
@@ -223,6 +225,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
assignRRMembersUsingSegment,
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
...evType
|
||||
@@ -332,6 +335,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
autoTranslateDescriptionEnabled,
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
@@ -358,6 +362,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
autoTranslateDescriptionEnabled,
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
@@ -420,6 +425,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
lockTimeZoneToggleOnBookingPage,
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
@@ -487,6 +493,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
autoTranslateDescriptionEnabled,
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
@@ -520,6 +527,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId,
|
||||
autoTranslateDescriptionEnabled,
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
@@ -533,6 +541,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
timeZone: "UTC",
|
||||
teamId: 1,
|
||||
autoTranslateDescriptionEnabled: false,
|
||||
autoTranslateInstantMeetingTitleEnabled: true,
|
||||
secondaryEmailId: null,
|
||||
schedulingType: SchedulingType.MANAGED,
|
||||
requiresBookerEmailVerification: false,
|
||||
|
||||
@@ -48,7 +48,7 @@ export default defineContentScript({
|
||||
|
||||
// Create iframe
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.src = "http://localhost:8081";
|
||||
iframe.src = "https://companion.cal.com";
|
||||
iframe.style.width = "400px";
|
||||
iframe.style.height = "100%";
|
||||
iframe.style.border = "none";
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"name": "Cal.com Companion",
|
||||
"version": "1.7.0",
|
||||
"description": "Your calendar companion for quick booking and scheduling",
|
||||
"permissions": ["activeTab", "http://localhost:8081/*", "identity"],
|
||||
"permissions": ["activeTab", "https://companion.cal.com/*", "identity"],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self'; frame-src 'self' http://localhost:8081;"
|
||||
"extension_pages": "script-src 'self'; object-src 'self'; frame-src 'self' https://companion.cal.com;"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "Cal.com Companion"
|
||||
|
||||
@@ -11,14 +11,14 @@ export default defineConfig({
|
||||
description: "Your calendar companion for quick booking and scheduling",
|
||||
permissions: ["activeTab", "storage", "identity"],
|
||||
host_permissions: [
|
||||
"http://localhost:8081/*",
|
||||
"https://companion.cal.com/*",
|
||||
"https://api.cal.com/*",
|
||||
"https://app.cal.com/*",
|
||||
"https://mail.google.com/*",
|
||||
],
|
||||
content_security_policy: {
|
||||
extension_pages:
|
||||
"script-src 'self'; object-src 'self'; frame-src 'self' http://localhost:8081;",
|
||||
"script-src 'self'; object-src 'self'; frame-src 'self' https://companion.cal.com",
|
||||
},
|
||||
action: {
|
||||
default_title: "Cal.com Companion",
|
||||
|
||||
@@ -248,6 +248,7 @@
|
||||
"platform/guides/replacing-toasts",
|
||||
"platform/guides/booking-fields",
|
||||
"platform/guides/booking-redirects",
|
||||
"platform/guides/managed-orgs",
|
||||
"platform/guides/teams-setup",
|
||||
"platform/guides/global-event-types",
|
||||
"platform/guides/custom-booking-flow"
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
title: Managed organizations
|
||||
description: Find out how to create managed organizations and their OAuth clients via the api.
|
||||
---
|
||||
|
||||
## **1. How to create a managed organizations**
|
||||
|
||||
First, manually create an OAuth client in [https://app.cal.com/settings/platform](https://app.cal.com/settings/platform).
|
||||
|
||||
Second, make a POST request to `https://api.cal.com/v2/organizations/:organizationId/organizations` to create a managed organization and receive its api key:
|
||||
|
||||
1. Replace `:organizationId` in the URL with your organization Id that you can copy from OAuth client settings.
|
||||
2. Add authentication headers `x-cal-client-id` equal to OAuth client id and `x-cal-secret-key` equal to the OAuth client secret.
|
||||
3. Add request body
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "some organization",
|
||||
"metadata": {
|
||||
"some": "metadata"
|
||||
},
|
||||
"apiKeyDaysValid": 60
|
||||
}
|
||||
```
|
||||
|
||||
Required fields: "name" - name of the managed organization.
|
||||
|
||||
Optional fields: "metadata", "apiKeyDaysValid", "apiKeyNeverExpires". By default the api key of a managed organization is valid for 30 days which means that you have to rotate them (will explain how below). If you want that api key is valid, for example, 60 days then specify that using "apiKeyDaysValid: 60" or if you want that api key never expires, set "apiKeyNeverExpires: true" in the request body.
|
||||
|
||||
Here is response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"id": 148,
|
||||
"name": "some organization",
|
||||
"metadata": {
|
||||
"some": "metadata"
|
||||
},
|
||||
"apiKey": "cal_xxx168ceb89117cc3a1b1f1777511506"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notably, you will only have access to the `apiKey` only now, so make sure to create a table in your database where you store managed organization's `id` and `apiKey` . The `apiKey` will be used to create an OAuth client that is used to create platform users.
|
||||
|
||||
Using same `x-cal-client-id` and `x-cal-secret-key` headers you can make requests to:
|
||||
|
||||
1. Fetch managed organization by making GET request to `https://api.cal.com/v2/organizations/:organizationId/organizations/:managedOrganizationId`
|
||||
2. Fetch all managed organizations by making GET request to `https://api.cal.com/v2/organizations/:organizationId/organizations`
|
||||
3. Update managed organization by making PATCH request to `https://api.cal.com/v2/organizations/:organizationId/organizations/:managedOrganizationId` with body where you can specify either "name" or "metadata"
|
||||
4. Delete managed organization by making DELETE request to `https://api.cal.com/v2/organizations/:organizationId/organizations/:managedOrganizationId`
|
||||
|
||||
Shortly, the flow is that using OAuth credentials of manager organization you create a managed organization and using its api key you can create and managed OAuth clients of the managed organization, and for the rest of the things like creating "managed users" of managed organization you will use OAuth client credentials.
|
||||
|
||||
## **2. How to create OAuth client for a managed organization**
|
||||
|
||||
Now that you have created a managed organization and have its `apiKey` you can create OAuth clients for the managed organization.
|
||||
|
||||
Make POST request to `https://api.cal.com/v2/oauth-clients` by:
|
||||
|
||||
1. Setting `Authorization: Bearer <apiKey>` header where `<apiKey>` is api key returned when creating a managed organization.
|
||||
2. Setting request body:
|
||||
1. name is name of the OAuth client
|
||||
2. permissions - an array that can receive following permissions: `"EVENT_TYPE_READ", "EVENT_TYPE_WRITE", "BOOKING_READ", "BOOKING_WRITE", "SCHEDULE_READ", "SCHEDULE_WRITE", "APPS_READ", "APPS_WRITE", "PROFILE_READ", "PROFILE_WRITE"` or if you want all of them enabled set `*` . You most probably want to use `*` . See docs here [https://cal.com/docs/platform/quickstart#2-setting-up-an-oauth-client](https://cal.com/docs/platform/quickstart#2-setting-up-an-oauth-client)
|
||||
3. redirectUris - point to (as example) `your-domain.com*` where users end up when connecting google calendar etc and what is the origin of the requests aka what origins are allowed to make requests to our endpoints. Having `your-domain.com*` would mean that we will accept requests from cal UI components that make requests from `your-domain.com, your-domain.com/page` etc.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "OAuth client for managed organization",
|
||||
"redirectUris": ["https://your-domain.com*"],
|
||||
"permissions": ["*"]
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"clientId": "xxxepq3bg0000khghtq4bxxx",
|
||||
"clientSecret": "xxxhbGciOiJIUzI1NiIsInR5cCI6IlpXVCJ9.eyJuYW1lIjoiT0F1dGggY2xpZW50IGZvciBtYW5hZ2VkIG9yZ2FuaXphdGlvbiIsInJlZGlyZWN0VXJpcyI6wyJodHRwOi8vbG9jYWxob3N0OjQzMjEiXSwicGVybWlzc2lvbnMiOjY0LCJpYXQiOjE3NDAxMzg4MjB9.3nzplyvqf-ZlMUuhX9mYfpxtneH62iJPB6WpsYBWxxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To fetch all OAuth clients of a managed organization make a GET request to `https://api.cal.com/v2/oauth-clients` by:
|
||||
|
||||
1. Setting `Authorization: Bearer <apiKey>` header where `<apiKey>` is the api key returned when creating a managed organization.
|
||||
|
||||
To fetch a specific OAuth client of a managed organization make a GET request to `https://api.cal.com/v2/oauth-clients/:oAuthClientId` by:
|
||||
|
||||
1. Setting `Authorization: Bearer <apiKey>` header where `<apiKey>` is the api key returned when creating a managed organization.
|
||||
|
||||
## **3. How to refresh api key of a managed organization**
|
||||
|
||||
If you did not specify `apiKeyNeverExpires` when creating a managed organization then you will have to refresh the api key after `apiKeyDaysValid` (if you specified it) or by default after 30 days.
|
||||
|
||||
Make a POST request to `https://api.cal.com/v2/api-keys/refresh` by:
|
||||
|
||||
1. Setting `Authorization: Bearer <apiKey>` header where `<apiKey>` is api key returned when creating a managed organization.
|
||||
2. Add a request body `{"apiKeyDaysValid": 30}` or whatever number of days you want the new api key to be valid or `{"apiKeyNeverExpires": true}` if you want that it never expires.
|
||||
|
||||
The response will contain the new api key for the managed organization:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"apiKey": "cal_xxx467709fffebe8c6559211cb5f178f"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
and now you have to update in your database api key of the managed organization.
|
||||
+2
-1
@@ -139,7 +139,8 @@
|
||||
"dayjs@1.11.2": "patch:dayjs@npm%3A1.11.4#./.yarn/patches/dayjs-npm-1.11.4-97921cd375.patch",
|
||||
"dayjs@^1": "patch:dayjs@npm%3A1.11.4#./.yarn/patches/dayjs-npm-1.11.4-97921cd375.patch",
|
||||
"dayjs@^1.8.29": "patch:dayjs@npm%3A1.11.4#./.yarn/patches/dayjs-npm-1.11.4-97921cd375.patch",
|
||||
"import-in-the-middle": "1.13.1"
|
||||
"import-in-the-middle": "1.13.1",
|
||||
"react@19.2.0": "19.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=7.0.0",
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { AppDependencyComponent } from "./AppDependencyComponent";
|
||||
|
||||
// Type for dependency data
|
||||
type MockDependency = {
|
||||
name: string;
|
||||
slug: string;
|
||||
installed: boolean;
|
||||
};
|
||||
|
||||
// Mock the useLocale hook
|
||||
vi.mock("@calcom/lib/hooks/useLocale", () => ({
|
||||
useLocale: () => ({
|
||||
t: (key: string, values?: Record<string, unknown>) => {
|
||||
if (key === "app_is_connected") {
|
||||
return `${values?.dependencyName} is connected`;
|
||||
}
|
||||
if (key === "this_app_requires_connected_account") {
|
||||
return `${values?.appName} requires a connected ${values?.dependencyName} account`;
|
||||
}
|
||||
if (key === "connect_app") {
|
||||
return `Connect ${values?.dependencyName}`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock constants and UI components
|
||||
vi.mock("@calcom/lib/constants", () => ({
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/ui/components/icon", () => ({
|
||||
Icon: ({ name, className }: { name: string; className?: string }) => (
|
||||
<svg data-testid={`${name}-icon`} className={className}>
|
||||
{name}
|
||||
</svg>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("AppDependencyComponent", () => {
|
||||
// Factory function to reduce duplication
|
||||
const createMockDependency = (overrides: Partial<MockDependency> = {}): MockDependency => ({
|
||||
name: "Google Calendar",
|
||||
slug: "google-calendar",
|
||||
installed: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows success indicators when dependencies are met", () => {
|
||||
const dependency = createMockDependency({ installed: true });
|
||||
const { container } = render(
|
||||
<AppDependencyComponent
|
||||
appName="Google Meet"
|
||||
dependencyData={[dependency]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass("bg-subtle");
|
||||
expect(screen.getByTestId("check-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("Google Calendar is connected")).toBeInTheDocument();
|
||||
expect(screen.getByText("Google Meet requires a connected Google Calendar account"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error indicators and connect link when dependencies are not met", () => {
|
||||
const dependency = createMockDependency({ installed: false });
|
||||
const { container } = render(
|
||||
<AppDependencyComponent
|
||||
appName="Google Meet"
|
||||
dependencyData={[dependency]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass("bg-error");
|
||||
expect(screen.getByTestId("circle-x-icon")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /connect google calendar/i }))
|
||||
.toHaveAttribute("href", expect.stringContaining("/apps/google-calendar"));
|
||||
expect(screen.getByText("Google Meet requires a connected Google Calendar account"))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows mixed states when some dependencies are unmet", () => {
|
||||
const dependencies = [
|
||||
createMockDependency({ installed: true }),
|
||||
createMockDependency({ installed: false }),
|
||||
];
|
||||
|
||||
const { container } = render(
|
||||
<AppDependencyComponent
|
||||
appName="Google Meet"
|
||||
dependencyData={dependencies}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass("bg-error");
|
||||
expect(screen.getByTestId("check-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("circle-x-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows only success indicators when all dependencies are met", () => {
|
||||
const dependencies = [
|
||||
createMockDependency({ installed: true }),
|
||||
createMockDependency({ name: "Zoom", slug: "zoom", installed: true }),
|
||||
];
|
||||
|
||||
const { container } = render(
|
||||
<AppDependencyComponent
|
||||
appName="Google Meet"
|
||||
dependencyData={dependencies}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass("bg-subtle");
|
||||
const checkIcons = screen.getAllByTestId("check-icon");
|
||||
expect(checkIcons).toHaveLength(2);
|
||||
expect(screen.queryByTestId("circle-x-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles empty dependency data gracefully", () => {
|
||||
const { container } = render(
|
||||
<AppDependencyComponent appName="Google Meet" dependencyData={undefined} />
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass("bg-subtle");
|
||||
expect(screen.queryByTestId("check-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("circle-x-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles empty dependency array gracefully", () => {
|
||||
const { container } = render(
|
||||
<AppDependencyComponent appName="Google Meet" dependencyData={[]} />
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass("bg-subtle");
|
||||
expect(screen.queryByTestId("check-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("circle-x-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("treats truthy non-boolean values as installed", () => {
|
||||
const dependency = createMockDependency({
|
||||
installed: "yes" as unknown as boolean
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<AppDependencyComponent
|
||||
appName="Google Meet"
|
||||
dependencyData={[dependency]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass("bg-subtle");
|
||||
expect(screen.getByTestId("check-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("Google Calendar is connected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays requirement message consistently", () => {
|
||||
const dependency = createMockDependency({ installed: true });
|
||||
render(
|
||||
<AppDependencyComponent
|
||||
appName="Google Meet"
|
||||
dependencyData={[dependency]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Google Meet requires a connected Google Calendar account")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -16,11 +16,13 @@ export const AppDependencyComponent = ({
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const hasUnmetDependencies = dependencyData ? dependencyData.some((dep) => !dep.installed) : false;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md px-4 py-3",
|
||||
dependencyData && dependencyData.some((dependency) => !dependency.installed) ? "bg-cal-info" : "bg-subtle"
|
||||
hasUnmetDependencies ? "bg-error" : "bg-subtle"
|
||||
)}>
|
||||
{dependencyData &&
|
||||
dependencyData.map((dependency) => {
|
||||
@@ -50,9 +52,9 @@ export const AppDependencyComponent = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="items-start space-x-2.5">
|
||||
<div className="text-info flex items-start">
|
||||
<div className="text-error flex items-start">
|
||||
<div>
|
||||
<Icon name="circle-alert" className="mr-2 mt-1 font-semibold" />
|
||||
<Icon name="circle-x" className="mr-2 mt-1 font-semibold" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">
|
||||
@@ -62,13 +64,12 @@ export const AppDependencyComponent = ({
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
</span>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<>
|
||||
<Link
|
||||
href={`${WEBAPP_URL}/apps/${dependency.slug}`}
|
||||
className="text-info flex items-center underline">
|
||||
className="text-error flex items-center underline">
|
||||
<span className="mr-1">
|
||||
{t("connect_app", { dependencyName: dependency.name })}
|
||||
</span>
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
|
||||
import AppCard from "@calcom/app-store/_components/AppCard";
|
||||
import useIsAppEnabled from "@calcom/app-store/_utils/useIsAppEnabled";
|
||||
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Section } from "@calcom/ui/components/section";
|
||||
import { Switch } from "@calcom/ui/components/form";
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType, onAppInstallSuccess }) {
|
||||
import type { appDataSchema } from "../zod";
|
||||
|
||||
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
app,
|
||||
eventType,
|
||||
onAppInstallSuccess,
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const { t } = useLocale();
|
||||
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
|
||||
const { enabled, updateEnabled } = useIsAppEnabled(app);
|
||||
|
||||
const ignoreGuests = getAppData("ignoreGuests") ?? false;
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
onAppInstallSuccess={onAppInstallSuccess}
|
||||
@@ -20,9 +33,26 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
updateEnabled(e);
|
||||
}}
|
||||
switchChecked={enabled}
|
||||
hideAppCardOptions
|
||||
/>
|
||||
hideSettingsIcon>
|
||||
<Section.Content>
|
||||
<Section.SubSection>
|
||||
<Section.SubSectionHeader
|
||||
icon="user-plus"
|
||||
title={t("hubspot_ignore_guests")}
|
||||
labelFor="ignore-guests">
|
||||
<Switch
|
||||
size="sm"
|
||||
labelOnLeading
|
||||
checked={ignoreGuests}
|
||||
onCheckedChange={(checked) => {
|
||||
setAppData("ignoreGuests", checked);
|
||||
}}
|
||||
/>
|
||||
</Section.SubSectionHeader>
|
||||
</Section.SubSection>
|
||||
</Section.Content>
|
||||
</AppCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTypeAppCard;
|
||||
export default EventTypeAppCard;
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
SimplePublicObject,
|
||||
SimplePublicObjectInput,
|
||||
} from "@hubspot/api-client/lib/codegen/crm/objects/meetings";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { getLocation } from "@calcom/lib/CalEventParser";
|
||||
import getLabelValueMapFromResponses from "@calcom/lib/bookings/getLabelValueMapFromResponses";
|
||||
@@ -20,6 +21,7 @@ import type { CRM, ContactCreateInput, Contact, CrmEvent } from "@calcom/types/C
|
||||
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
||||
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
|
||||
import type { HubspotToken } from "../api/callback";
|
||||
import type { appDataSchema } from "../zod";
|
||||
|
||||
export default class HubspotCalendarService implements CRM {
|
||||
private url = "";
|
||||
@@ -29,8 +31,9 @@ export default class HubspotCalendarService implements CRM {
|
||||
private client_id = "";
|
||||
private client_secret = "";
|
||||
private hubspotClient: hubspot.Client;
|
||||
private appOptions: z.infer<typeof appDataSchema>;
|
||||
|
||||
constructor(credential: CredentialPayload) {
|
||||
constructor(credential: CredentialPayload, appOptions?: z.infer<typeof appDataSchema>) {
|
||||
this.hubspotClient = new hubspot.Client();
|
||||
|
||||
this.integrationName = "hubspot_other_calendar";
|
||||
@@ -38,6 +41,8 @@ export default class HubspotCalendarService implements CRM {
|
||||
this.auth = this.hubspotAuth(credential).then((r) => r);
|
||||
|
||||
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
|
||||
|
||||
this.appOptions = appOptions || {};
|
||||
}
|
||||
|
||||
private getHubspotMeetingBody = (event: CalendarEvent): string => {
|
||||
@@ -300,7 +305,7 @@ export default class HubspotCalendarService implements CRM {
|
||||
}
|
||||
|
||||
getAppOptions() {
|
||||
console.log("No options implemented");
|
||||
return this.appOptions;
|
||||
}
|
||||
|
||||
async handleAttendeeNoShow() {
|
||||
|
||||
@@ -2,7 +2,9 @@ import { z } from "zod";
|
||||
|
||||
import { eventTypeAppCardZod } from "../eventTypeAppCardZod";
|
||||
|
||||
export const appDataSchema = eventTypeAppCardZod;
|
||||
export const appDataSchema = eventTypeAppCardZod.extend({
|
||||
ignoreGuests: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const appKeysSchema = z.object({
|
||||
client_id: z.string().min(1),
|
||||
|
||||
@@ -56,7 +56,7 @@ export const sendEmailVerification = async ({
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: hashEmail(email),
|
||||
identifier: `sendEmailVerification:${hashEmail(email)}`,
|
||||
});
|
||||
|
||||
await prisma.verificationToken.create({
|
||||
|
||||
@@ -361,20 +361,20 @@ Used when a booking status changes to accepted.
|
||||
#### ATTENDEE_ADDED
|
||||
```typescript
|
||||
{
|
||||
addedAttendees // { old: null, new: ["email@example.com", ...] }
|
||||
attendees // { old: ["email1@example.com", ...], new: ["email1@example.com", "email2@example.com", ...] }
|
||||
}
|
||||
```
|
||||
|
||||
Tracks attendee(s) that were added in this action. Old value is null since we're tracking the delta, not full state.
|
||||
Tracks attendee(s) that were added in this action. The field stores the state change: `old` contains attendees before addition, `new` contains attendees after addition. The actual added attendees are computed as the difference (new - old).
|
||||
|
||||
#### ATTENDEE_REMOVED
|
||||
```typescript
|
||||
{
|
||||
removedAttendees // { old: null, new: ["email@example.com", ...] }
|
||||
attendees // { old: ["email1@example.com", ...], new: ["email2@example.com", ...] }
|
||||
}
|
||||
```
|
||||
|
||||
Tracks attendee(s) that were removed in this action. Old value is null since we're tracking the delta, not full state.
|
||||
Tracks attendee(s) that were removed in this action. The field stores the state change: `old` contains attendees before removal, `new` contains remaining attendees after removal. The actual removed attendees are computed as the difference (old - new).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { StringChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Accepted Audit Action Service
|
||||
* Handles ACCEPTED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
status: StringChangeSchema,
|
||||
});
|
||||
|
||||
export class AcceptedAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "ACCEPTED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = AcceptedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = AcceptedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof AcceptedAuditActionService.latestFieldsSchema,
|
||||
typeof AcceptedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: AcceptedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: AcceptedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.accepted" };
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AcceptedAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
return {
|
||||
previousStatus: fields.status.old ?? null,
|
||||
newStatus: fields.status.new ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type AcceptedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type AcceptedAuditDisplayData = {
|
||||
previousStatus: string | null;
|
||||
newStatus: string | null;
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { StringArrayChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Attendee Added Audit Action Service
|
||||
* Handles ATTENDEE_ADDED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
attendees: StringArrayChangeSchema,
|
||||
});
|
||||
|
||||
export class AttendeeAddedAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "ATTENDEE_ADDED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = AttendeeAddedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = AttendeeAddedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof AttendeeAddedAuditActionService.latestFieldsSchema,
|
||||
typeof AttendeeAddedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: AttendeeAddedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: AttendeeAddedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.attendee_added" };
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AttendeeAddedAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
const previousAttendeesSet = new Set(fields.attendees.old ?? []);
|
||||
const addedAttendees = fields.attendees.new.filter(
|
||||
(email) => !previousAttendeesSet.has(email)
|
||||
);
|
||||
return {
|
||||
addedAttendees,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type AttendeeAddedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type AttendeeAddedAuditDisplayData = {
|
||||
addedAttendees: string[];
|
||||
};
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { BooleanChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Attendee No-Show Updated Audit Action Service
|
||||
* Handles ATTENDEE_NO_SHOW_UPDATED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
noShowAttendee: BooleanChangeSchema,
|
||||
});
|
||||
|
||||
export class AttendeeNoShowUpdatedAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "ATTENDEE_NO_SHOW_UPDATED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = AttendeeNoShowUpdatedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = AttendeeNoShowUpdatedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema,
|
||||
typeof AttendeeNoShowUpdatedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: AttendeeNoShowUpdatedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: AttendeeNoShowUpdatedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.attendee_no_show_updated" };
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AttendeeNoShowUpdatedAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
return {
|
||||
noShowAttendee: fields.noShowAttendee.new,
|
||||
previousNoShowAttendee: fields.noShowAttendee.old ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type AttendeeNoShowUpdatedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type AttendeeNoShowUpdatedAuditDisplayData = {
|
||||
noShowAttendee: boolean;
|
||||
previousNoShowAttendee: boolean | null;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { StringArrayChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Attendee Removed Audit Action Service
|
||||
* Handles ATTENDEE_REMOVED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
attendees: StringArrayChangeSchema,
|
||||
});
|
||||
|
||||
export class AttendeeRemovedAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "ATTENDEE_REMOVED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = AttendeeRemovedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = AttendeeRemovedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof AttendeeRemovedAuditActionService.latestFieldsSchema,
|
||||
typeof AttendeeRemovedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: AttendeeRemovedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: AttendeeRemovedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.attendee_removed" };
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): AttendeeRemovedAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
// Note: fields.attendees stores the state change (old -> new), not the removed attendees directly
|
||||
// old = attendees before removal, new = remaining attendees after removal
|
||||
const remainingAttendeesSet = new Set(fields.attendees.new ?? []);
|
||||
// Compute removed attendees: those in old but not in new (remaining)
|
||||
const removedAttendees = (fields.attendees.old ?? []).filter(
|
||||
(email) => !remainingAttendeesSet.has(email)
|
||||
);
|
||||
return {
|
||||
removedAttendees,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type AttendeeRemovedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type AttendeeRemovedAuditDisplayData = {
|
||||
removedAttendees: string[];
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { StringChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Cancelled Audit Action Service
|
||||
* Handles CANCELLED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
cancellationReason: StringChangeSchema,
|
||||
cancelledBy: StringChangeSchema,
|
||||
status: StringChangeSchema,
|
||||
});
|
||||
|
||||
export class CancelledAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "CANCELLED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = CancelledAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = CancelledAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof CancelledAuditActionService.latestFieldsSchema,
|
||||
typeof CancelledAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: CancelledAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: CancelledAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.cancelled" };
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): CancelledAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
return {
|
||||
cancellationReason: fields.cancellationReason.new ?? null,
|
||||
previousReason: fields.cancellationReason.old ?? null,
|
||||
cancelledBy: fields.cancelledBy.new ?? null,
|
||||
previousCancelledBy: fields.cancelledBy.old ?? null,
|
||||
previousStatus: fields.status.old ?? null,
|
||||
newStatus: fields.status.new ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type CancelledAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type CancelledAuditDisplayData = {
|
||||
cancellationReason: string | null;
|
||||
previousReason: string | null;
|
||||
cancelledBy: string | null;
|
||||
previousCancelledBy: string | null;
|
||||
previousStatus: string | null;
|
||||
newStatus: string | null;
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService } from "./IAuditActionService";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Created Audit Action Service
|
||||
@@ -22,7 +22,7 @@ export class CreatedAuditActionService implements IAuditActionService<
|
||||
typeof fieldsSchemaV1
|
||||
> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "CREATED";
|
||||
public static readonly TYPE = "CREATED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
@@ -61,11 +61,16 @@ export class CreatedAuditActionService implements IAuditActionService<
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.created" };
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): CreatedAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
return {
|
||||
startTime: new Date(storedData.fields.startTime).toISOString(),
|
||||
endTime: new Date(storedData.fields.endTime).toISOString(),
|
||||
status: storedData.fields.status,
|
||||
startTime: new Date(fields.startTime).toISOString(),
|
||||
endTime: new Date(fields.endTime).toISOString(),
|
||||
status: fields.status,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { BooleanChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Host No-Show Updated Audit Action Service
|
||||
* Handles HOST_NO_SHOW_UPDATED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
noShowHost: BooleanChangeSchema,
|
||||
});
|
||||
|
||||
export class HostNoShowUpdatedAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "HOST_NO_SHOW_UPDATED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = HostNoShowUpdatedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = HostNoShowUpdatedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof HostNoShowUpdatedAuditActionService.latestFieldsSchema,
|
||||
typeof HostNoShowUpdatedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: HostNoShowUpdatedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: HostNoShowUpdatedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.host_no_show_updated" };
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): HostNoShowUpdatedAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
return {
|
||||
noShowHost: fields.noShowHost.new,
|
||||
previousNoShowHost: fields.noShowHost.old ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type HostNoShowUpdatedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type HostNoShowUpdatedAuditDisplayData = {
|
||||
noShowHost: boolean;
|
||||
previousNoShowHost: boolean | null;
|
||||
};
|
||||
@@ -1,5 +1,25 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Represents a component that can be interpolated into translations
|
||||
* Used with react-i18next Trans component for proper i18n support (RTL, word order, etc.)
|
||||
*/
|
||||
export type TranslationComponent = {
|
||||
type: "link";
|
||||
href: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a translation key with optional interpolation params and components
|
||||
* Used for dynamic display titles that need to be translated with context
|
||||
* Components are used for clickable links within translations (e.g., "Rescheduled to <1>New Booking</1>")
|
||||
*/
|
||||
export type TranslationWithParams = {
|
||||
key: string;
|
||||
params?: Record<string, string | number>;
|
||||
components?: TranslationComponent[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Interface for Audit Action Services
|
||||
*
|
||||
@@ -43,10 +63,20 @@ export interface IAuditActionService<
|
||||
|
||||
/**
|
||||
* Get flattened JSON data for display (fields only, no version wrapper)
|
||||
* Optional - implement only if custom display formatting is needed
|
||||
* @param storedData - Parsed stored data { version, fields }
|
||||
* @returns The fields object without version wrapper and we decide what fields to show to the client
|
||||
*/
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<TStoredFieldsSchema> }): unknown;
|
||||
getDisplayJson?(storedData: { version: number; fields: z.infer<TStoredFieldsSchema> }): unknown;
|
||||
|
||||
/**
|
||||
* Get the display title for the audit action
|
||||
* Returns a translation key with optional interpolation params for dynamic titles
|
||||
* (e.g., "Booking reassigned to John Doe" instead of just "Reassignment")
|
||||
* @param storedData - Parsed stored data { version, fields }
|
||||
* @returns Translation key with optional interpolation params
|
||||
*/
|
||||
getDisplayTitle(storedData: { version: number; fields: z.infer<TStoredFieldsSchema> }): Promise<TranslationWithParams>;
|
||||
|
||||
/**
|
||||
* Migrate old version data to latest version
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getHumanReadableLocationValue } from "@calcom/app-store/locations";
|
||||
import { StringChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
/**
|
||||
* Location Changed Audit Action Service
|
||||
* Handles LOCATION_CHANGED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
location: StringChangeSchema,
|
||||
});
|
||||
|
||||
export class LocationChangedAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "LOCATION_CHANGED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = LocationChangedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = LocationChangedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof LocationChangedAuditActionService.latestFieldsSchema,
|
||||
typeof LocationChangedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: LocationChangedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: LocationChangedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): Promise<TranslationWithParams> {
|
||||
const { fields } = storedData;
|
||||
// TODO: Ideally we want to translate the location label to the user's locale
|
||||
// We currently don't accept requesting user's translate fn here, fix it later.
|
||||
const t = await getTranslation("en", "common");
|
||||
|
||||
const fromLocation = getHumanReadableLocationValue(fields.location.old, t);
|
||||
const toLocation = getHumanReadableLocationValue(fields.location.new, t);
|
||||
|
||||
return {
|
||||
key: "booking_audit_action.location_changed_from_to",
|
||||
params: { fromLocation, toLocation },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type LocationChangedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
@@ -0,0 +1,91 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { StringChangeSchema, NumberChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Reassignment Audit Action Service
|
||||
* Handles REASSIGNMENT action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
assignedToId: NumberChangeSchema,
|
||||
assignedById: NumberChangeSchema,
|
||||
reassignmentReason: StringChangeSchema,
|
||||
userPrimaryEmail: StringChangeSchema.optional(),
|
||||
title: StringChangeSchema.optional(),
|
||||
});
|
||||
|
||||
export class ReassignmentAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "REASSIGNMENT" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = ReassignmentAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = ReassignmentAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof ReassignmentAuditActionService.latestFieldsSchema,
|
||||
typeof ReassignmentAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor(private userRepository: UserRepository) {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: ReassignmentAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: ReassignmentAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): Promise<TranslationWithParams> {
|
||||
const { fields } = storedData;
|
||||
const user = await this.userRepository.findById({ id: fields.assignedToId.new });
|
||||
const reassignedToName = user?.name || "Unknown";
|
||||
return {
|
||||
key: "booking_audit_action.booking_reassigned_to_host",
|
||||
params: { host: reassignedToName },
|
||||
};
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): ReassignmentAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
return {
|
||||
newAssignedToId: fields.assignedToId.new,
|
||||
reassignmentReason: fields.reassignmentReason.new ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type ReassignmentAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type ReassignmentAuditDisplayData = {
|
||||
newAssignedToId: number;
|
||||
reassignmentReason: string | null;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { StringChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Rejected Audit Action Service
|
||||
* Handles REJECTED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
rejectionReason: StringChangeSchema,
|
||||
status: StringChangeSchema,
|
||||
});
|
||||
|
||||
export class RejectedAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "REJECTED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = RejectedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = RejectedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof RejectedAuditActionService.latestFieldsSchema,
|
||||
typeof RejectedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: RejectedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: RejectedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.rejected" };
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): RejectedAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
return {
|
||||
rejectionReason: fields.rejectionReason.new ?? null,
|
||||
previousReason: fields.rejectionReason.old ?? null,
|
||||
previousStatus: fields.status.old ?? null,
|
||||
newStatus: fields.status.new ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type RejectedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type RejectedAuditDisplayData = {
|
||||
rejectionReason: string | null;
|
||||
previousReason: string | null;
|
||||
previousStatus: string | null;
|
||||
newStatus: string | null;
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { StringChangeSchema, BooleanChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Reschedule Requested Audit Action Service
|
||||
* Handles RESCHEDULE_REQUESTED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
cancellationReason: StringChangeSchema,
|
||||
cancelledBy: StringChangeSchema,
|
||||
rescheduled: BooleanChangeSchema.optional(),
|
||||
});
|
||||
|
||||
export class RescheduleRequestedAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "RESCHEDULE_REQUESTED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = RescheduleRequestedAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = RescheduleRequestedAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof RescheduleRequestedAuditActionService.latestFieldsSchema,
|
||||
typeof RescheduleRequestedAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: RescheduleRequestedAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: RescheduleRequestedAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(): Promise<TranslationWithParams> {
|
||||
return { key: "booking_audit_action.reschedule_requested" };
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): RescheduleRequestedAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
return {
|
||||
reason: fields.cancellationReason.new ?? null,
|
||||
requestedBy: fields.cancelledBy.new ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type RescheduleRequestedAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type RescheduleRequestedAuditDisplayData = {
|
||||
reason: string | null;
|
||||
requestedBy: string | null;
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { StringChangeSchema } from "../common/changeSchemas";
|
||||
import { AuditActionServiceHelper } from "./AuditActionServiceHelper";
|
||||
import type { IAuditActionService, TranslationWithParams } from "./IAuditActionService";
|
||||
|
||||
/**
|
||||
* Rescheduled Audit Action Service
|
||||
* Handles RESCHEDULED action with per-action versioning
|
||||
*/
|
||||
|
||||
// Module-level because it is passed to IAuditActionService type outside the class scope
|
||||
const fieldsSchemaV1 = z.object({
|
||||
startTime: StringChangeSchema,
|
||||
endTime: StringChangeSchema,
|
||||
rescheduledToUid: StringChangeSchema,
|
||||
});
|
||||
|
||||
export class RescheduledAuditActionService
|
||||
implements IAuditActionService<typeof fieldsSchemaV1, typeof fieldsSchemaV1> {
|
||||
readonly VERSION = 1;
|
||||
public static readonly TYPE = "RESCHEDULED" as const;
|
||||
private static dataSchemaV1 = z.object({
|
||||
version: z.literal(1),
|
||||
fields: fieldsSchemaV1,
|
||||
});
|
||||
private static fieldsSchemaV1 = fieldsSchemaV1;
|
||||
public static readonly latestFieldsSchema = fieldsSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedDataSchema = RescheduledAuditActionService.dataSchemaV1;
|
||||
// Union of all versions
|
||||
public static readonly storedFieldsSchema = RescheduledAuditActionService.fieldsSchemaV1;
|
||||
private helper: AuditActionServiceHelper<
|
||||
typeof RescheduledAuditActionService.latestFieldsSchema,
|
||||
typeof RescheduledAuditActionService.storedDataSchema
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this.helper = new AuditActionServiceHelper({
|
||||
latestVersion: this.VERSION,
|
||||
latestFieldsSchema: RescheduledAuditActionService.latestFieldsSchema,
|
||||
storedDataSchema: RescheduledAuditActionService.storedDataSchema,
|
||||
});
|
||||
}
|
||||
|
||||
getVersionedData(fields: unknown) {
|
||||
return this.helper.getVersionedData(fields);
|
||||
}
|
||||
|
||||
parseStored(data: unknown) {
|
||||
return this.helper.parseStored(data);
|
||||
}
|
||||
|
||||
getVersion(data: unknown): number {
|
||||
return this.helper.getVersion(data);
|
||||
}
|
||||
|
||||
migrateToLatest(data: unknown) {
|
||||
// V1-only: validate and return as-is (no migration needed)
|
||||
const validated = fieldsSchemaV1.parse(data);
|
||||
return { isMigrated: false, latestData: validated };
|
||||
}
|
||||
|
||||
async getDisplayTitle(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): Promise<TranslationWithParams> {
|
||||
const rescheduledToUid = storedData.fields.rescheduledToUid.new;
|
||||
return {
|
||||
key: "booking_audit_action.rescheduled",
|
||||
components: rescheduledToUid ? [{ type: "link", href: `/booking/${rescheduledToUid}` }] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
getDisplayJson(storedData: { version: number; fields: z.infer<typeof fieldsSchemaV1> }): RescheduledAuditDisplayData {
|
||||
const { fields } = storedData;
|
||||
return {
|
||||
previousStartTime: fields.startTime.old ?? null,
|
||||
newStartTime: fields.startTime.new ?? null,
|
||||
previousEndTime: fields.endTime.old ?? null,
|
||||
newEndTime: fields.endTime.new ?? null,
|
||||
rescheduledToUid: fields.rescheduledToUid.new ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the rescheduled log that created a specific booking
|
||||
* by matching the rescheduledToUid field with the target booking UID
|
||||
* @param rescheduledLogs - Array of rescheduled audit logs to search through
|
||||
* @param rescheduledToBookingUid - The UID of the booking that was created from the reschedule
|
||||
* @returns The matching log or null if not found
|
||||
*/
|
||||
getMatchingLog<T extends { data: unknown }>({
|
||||
rescheduledLogs,
|
||||
rescheduledToBookingUid,
|
||||
}: {
|
||||
rescheduledLogs: T[];
|
||||
rescheduledToBookingUid: string;
|
||||
}): T | null {
|
||||
return rescheduledLogs.find((log) => {
|
||||
const parsedData = this.parseStored(log.data);
|
||||
return parsedData.fields.rescheduledToUid.new === rescheduledToBookingUid;
|
||||
}) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export type RescheduledAuditData = z.infer<typeof fieldsSchemaV1>;
|
||||
|
||||
export type RescheduledAuditDisplayData = {
|
||||
previousStartTime: string | null;
|
||||
newStartTime: string | null;
|
||||
previousEndTime: string | null;
|
||||
newEndTime: string | null;
|
||||
rescheduledToUid: string | null;
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Common change schemas for audit data
|
||||
* These represent old -> new value transitions
|
||||
*/
|
||||
|
||||
export const StringChangeSchema = z.object({
|
||||
old: z.string().nullable(),
|
||||
new: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const BooleanChangeSchema = z.object({
|
||||
old: z.boolean().nullable(),
|
||||
new: z.boolean(),
|
||||
});
|
||||
|
||||
export const StringArrayChangeSchema = z.object({
|
||||
old: z.array(z.string()).nullable(),
|
||||
new: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const NumberChangeSchema = z.object({
|
||||
old: z.number().nullable(),
|
||||
new: z.number(),
|
||||
});
|
||||
|
||||
@@ -56,24 +56,7 @@ describe("Booking Audit Integration", () => {
|
||||
});
|
||||
testEventTypeId = eventType.id;
|
||||
|
||||
// Create test booking
|
||||
const startTime = new Date();
|
||||
const endTime = new Date(startTime.getTime() + 60 * 60 * 1000);
|
||||
testBookingUid = `test-booking-${timestamp}-${randomSuffix}`;
|
||||
|
||||
const testBooking = await prisma.booking.create({
|
||||
data: {
|
||||
uid: testBookingUid,
|
||||
title: "Test Booking",
|
||||
startTime,
|
||||
endTime,
|
||||
userId: testUserId,
|
||||
eventTypeId: testEventTypeId,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
|
||||
// Create test attendee user for permission tests
|
||||
// Create test attendee user for permission tests (needed before booking creation)
|
||||
const attendeeUser = await prisma.user.create({
|
||||
data: {
|
||||
email: `test-attendee-${timestamp}-${randomSuffix}@example.com`,
|
||||
@@ -84,13 +67,29 @@ describe("Booking Audit Integration", () => {
|
||||
testAttendeeUserId = attendeeUser.id;
|
||||
testAttendeeEmail = attendeeUser.email;
|
||||
|
||||
// Add attendee to booking
|
||||
await prisma.attendee.create({
|
||||
// Create test booking with attendee in single atomic operation
|
||||
const startTime = new Date();
|
||||
const endTime = new Date(startTime.getTime() + 60 * 60 * 1000);
|
||||
testBookingUid = `test-booking-${timestamp}-${randomSuffix}`;
|
||||
|
||||
await prisma.booking.create({
|
||||
data: {
|
||||
email: testAttendeeEmail,
|
||||
name: "Test Attendee",
|
||||
timeZone: "UTC",
|
||||
bookingId: testBooking.id,
|
||||
uid: testBookingUid,
|
||||
title: "Test Booking",
|
||||
startTime,
|
||||
endTime,
|
||||
userId: testUserId,
|
||||
eventTypeId: testEventTypeId,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
attendees: {
|
||||
create: [
|
||||
{
|
||||
email: testAttendeeEmail,
|
||||
name: "Test Attendee",
|
||||
timeZone: "UTC",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,7 @@ export type BookingToDelete = Awaited<ReturnType<typeof getBookingToDelete>>;
|
||||
|
||||
export type CancelBookingInput = {
|
||||
userId?: number;
|
||||
userUuid?: string;
|
||||
bookingData: z.infer<typeof bookingCancelInput>;
|
||||
} & PlatformParams;
|
||||
|
||||
|
||||
@@ -181,6 +181,8 @@ const getEventTypesFromDBSelect = {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
instantMeetingExpiryTimeOffsetInSeconds: true,
|
||||
autoTranslateInstantMeetingTitleEnabled: true,
|
||||
} satisfies Prisma.EventTypeSelect;
|
||||
|
||||
export const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||
|
||||
@@ -197,9 +197,19 @@ export async function handler(
|
||||
const attendeeTimezone = reqBody.timeZone;
|
||||
const attendeeLanguage = reqBody.language;
|
||||
const tAttendees = await getTranslation(attendeeLanguage ?? "en", "common");
|
||||
const tEnglish = await getTranslation("en", "common");
|
||||
|
||||
const fullName = getFullName(bookerName);
|
||||
|
||||
// Determine whether to auto-translate the instant meeting title based on the event type setting
|
||||
// Default is true (opt-out), so we only skip translation when explicitly set to false
|
||||
const shouldAutoTranslateInstantMeetingTitle = eventType.autoTranslateInstantMeetingTitleEnabled;
|
||||
|
||||
// Get the booking title - either translated to attendee's language or in English
|
||||
const bookingTitle = shouldAutoTranslateInstantMeetingTitle
|
||||
? tAttendees("instant_meeting_with_title", { name: fullName })
|
||||
: tEnglish("instant_meeting_with_title", { name: fullName });
|
||||
|
||||
const invitee = [
|
||||
{
|
||||
email: bookerEmail,
|
||||
@@ -242,7 +252,7 @@ export async function handler(
|
||||
const newBookingData: Prisma.BookingCreateInput = {
|
||||
uid,
|
||||
responses: reqBody.responses === null ? Prisma.JsonNull : reqBody.responses,
|
||||
title: tAttendees("instant_meeting_with_title", { name: invitee[0].name }),
|
||||
title: bookingTitle,
|
||||
startTime: dayjs.utc(reqBody.start).toDate(),
|
||||
endTime: dayjs.utc(reqBody.end).toDate(),
|
||||
description: reqBody.notes,
|
||||
@@ -279,17 +289,7 @@ export async function handler(
|
||||
|
||||
const token = randomBytes(32).toString("hex");
|
||||
|
||||
const eventTypeWithExpiryTimeOffset = await prisma.eventType.findUniqueOrThrow({
|
||||
where: {
|
||||
id: bookingData.eventTypeId,
|
||||
},
|
||||
select: {
|
||||
instantMeetingExpiryTimeOffsetInSeconds: true,
|
||||
},
|
||||
});
|
||||
|
||||
const instantMeetingExpiryTimeOffsetInSeconds =
|
||||
eventTypeWithExpiryTimeOffset?.instantMeetingExpiryTimeOffsetInSeconds ?? 90;
|
||||
const instantMeetingExpiryTimeOffsetInSeconds = eventType.instantMeetingExpiryTimeOffsetInSeconds ?? 90;
|
||||
|
||||
const instantMeetingToken = await prisma.instantMeetingToken.create({
|
||||
data: {
|
||||
|
||||
@@ -35,8 +35,8 @@ import { handlePayment } from "@calcom/features/bookings/lib/handlePayment";
|
||||
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
|
||||
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
|
||||
import { BookingEventHandlerService } from "@calcom/features/bookings/lib/onBookingEvents/BookingEventHandlerService";
|
||||
import { BookingEmailAndSmsTasker } from "@calcom/features/bookings/lib/tasker/BookingEmailAndSmsTasker";
|
||||
import type { BookingRescheduledPayload } from "@calcom/features/bookings/lib/onBookingEvents/types.d";
|
||||
import { BookingEmailAndSmsTasker } from "@calcom/features/bookings/lib/tasker/BookingEmailAndSmsTasker";
|
||||
import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/SpamCheckService.container";
|
||||
import { CreditService } from "@calcom/features/ee/billing/credit-service";
|
||||
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
|
||||
@@ -456,7 +456,6 @@ function buildBookingCreatedPayload({
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export interface IBookingServiceDependencies {
|
||||
checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService;
|
||||
prismaClient: PrismaClient;
|
||||
@@ -1310,9 +1309,9 @@ async function handler(
|
||||
// This ensures that createMeeting isn't called for static video apps as bookingLocation becomes just a regular value for them.
|
||||
const { bookingLocation, conferenceCredentialId } = organizerOrFirstDynamicGroupMemberDefaultLocationUrl
|
||||
? {
|
||||
bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl,
|
||||
conferenceCredentialId: undefined,
|
||||
}
|
||||
bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl,
|
||||
conferenceCredentialId: undefined,
|
||||
}
|
||||
: getLocationValueForDB(locationBodyString, eventType.locations);
|
||||
|
||||
tracingLogger.info("locationBodyString", locationBodyString);
|
||||
@@ -1358,8 +1357,8 @@ async function handler(
|
||||
const destinationCalendar = eventType.destinationCalendar
|
||||
? [eventType.destinationCalendar]
|
||||
: organizerUser.destinationCalendar
|
||||
? [organizerUser.destinationCalendar]
|
||||
: null;
|
||||
? [organizerUser.destinationCalendar]
|
||||
: null;
|
||||
|
||||
let organizerEmail = organizerUser.email || "Email-less";
|
||||
if (eventType.useEventTypeDestinationCalendarEmail && destinationCalendar?.[0]?.primaryEmail) {
|
||||
@@ -1980,18 +1979,18 @@ async function handler(
|
||||
}
|
||||
const updateManager = !skipCalendarSyncTaskCreation
|
||||
? await eventManager.reschedule(
|
||||
evt,
|
||||
originalRescheduledBooking.uid,
|
||||
undefined,
|
||||
changedOrganizer,
|
||||
previousHostDestinationCalendar,
|
||||
isBookingRequestedReschedule,
|
||||
skipDeleteEventsAndMeetings
|
||||
)
|
||||
evt,
|
||||
originalRescheduledBooking.uid,
|
||||
undefined,
|
||||
changedOrganizer,
|
||||
previousHostDestinationCalendar,
|
||||
isBookingRequestedReschedule,
|
||||
skipDeleteEventsAndMeetings
|
||||
)
|
||||
: placeholderCreatedEvent;
|
||||
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
|
||||
// to the default description when we are sending the emails.
|
||||
evt.description = eventType.description;
|
||||
evt.description = eventType.description ?? evt.description;
|
||||
|
||||
results = updateManager.results;
|
||||
referencesToCreate = updateManager.referencesToCreate;
|
||||
@@ -2288,8 +2287,8 @@ async function handler(
|
||||
|
||||
const metadata = videoCallUrl
|
||||
? {
|
||||
videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl,
|
||||
}
|
||||
videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const bookingCreatedPayload = buildBookingCreatedPayload({
|
||||
@@ -2303,10 +2302,12 @@ async function handler(
|
||||
|
||||
const bookingRescheduledPayload: BookingRescheduledPayload = {
|
||||
...bookingCreatedPayload,
|
||||
oldBooking: originalRescheduledBooking ? {
|
||||
startTime: originalRescheduledBooking.startTime,
|
||||
endTime: originalRescheduledBooking.endTime,
|
||||
} : undefined,
|
||||
oldBooking: originalRescheduledBooking
|
||||
? {
|
||||
startTime: originalRescheduledBooking.startTime,
|
||||
endTime: originalRescheduledBooking.endTime,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const bookingEventHandler = deps.bookingEventHandler;
|
||||
@@ -2382,9 +2383,9 @@ async function handler(
|
||||
...eventType,
|
||||
metadata: eventType.metadata
|
||||
? {
|
||||
...eventType.metadata,
|
||||
apps: eventType.metadata?.apps as Prisma.JsonValue,
|
||||
}
|
||||
...eventType.metadata,
|
||||
apps: eventType.metadata?.apps as Prisma.JsonValue,
|
||||
}
|
||||
: {},
|
||||
},
|
||||
paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
|
||||
@@ -2718,7 +2719,7 @@ async function handler(
|
||||
* We are open to renaming it to something more descriptive.
|
||||
*/
|
||||
export class RegularBookingService implements IBookingService {
|
||||
constructor(private readonly deps: IBookingServiceDependencies) { }
|
||||
constructor(private readonly deps: IBookingServiceDependencies) {}
|
||||
|
||||
async createBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) {
|
||||
return handler({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps);
|
||||
|
||||
@@ -917,7 +917,7 @@ describe("RetellAIService", () => {
|
||||
|
||||
expect(vi.mocked(checkRateLimitAndThrowError)).toHaveBeenCalledWith({
|
||||
rateLimitingType: "core",
|
||||
identifier: "test-call:1",
|
||||
identifier: "createTestCall:1",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
callId: "call-123",
|
||||
|
||||
@@ -97,7 +97,7 @@ export class CallService {
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `test-call:${userId}`,
|
||||
identifier: `createTestCall:${userId}`,
|
||||
});
|
||||
|
||||
const toNumber = phoneNumber?.trim();
|
||||
@@ -192,7 +192,7 @@ export class CallService {
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `web-call:${userId}`,
|
||||
identifier: `createWebCall:${userId}`,
|
||||
});
|
||||
|
||||
const agent = await this.deps.agentRepository.findByIdWithCallAccess({
|
||||
|
||||
+2
-2
@@ -207,7 +207,7 @@ describe("CallService", () => {
|
||||
|
||||
expect(checkRateLimitAndThrowError).toHaveBeenCalledWith({
|
||||
rateLimitingType: "core",
|
||||
identifier: "test-call:1",
|
||||
identifier: "createTestCall:1",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,7 +224,7 @@ describe("CallService", () => {
|
||||
|
||||
expect(checkRateLimitAndThrowError).toHaveBeenCalledWith({
|
||||
rateLimitingType: "core",
|
||||
identifier: "test-call:42",
|
||||
identifier: "createTestCall:42",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -277,39 +277,47 @@ const handleDeleteCredential = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const unpaidBookingsIds = unpaidBookings.map((booking) => booking.id);
|
||||
const unpaidBookingsPaymentIds = unpaidBookings.flatMap((booking) =>
|
||||
booking.payment.map((payment) => payment.id)
|
||||
);
|
||||
await prisma.booking.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: unpaidBookingsIds,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
cancellationReason: "Payment method removed",
|
||||
},
|
||||
});
|
||||
for (const paymentId of unpaidBookingsPaymentIds) {
|
||||
await deletePayment(paymentId, credential);
|
||||
}
|
||||
await prisma.payment.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: unpaidBookingsPaymentIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.attendee.deleteMany({
|
||||
where: {
|
||||
bookingId: {
|
||||
in: unpaidBookingsIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.bookingReference.updateMany({
|
||||
where: {
|
||||
bookingId: {
|
||||
in: unpaidBookingsIds,
|
||||
},
|
||||
},
|
||||
data: { deleted: true },
|
||||
});
|
||||
for (const booking of unpaidBookings) {
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: booking.id,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
cancellationReason: "Payment method removed",
|
||||
},
|
||||
});
|
||||
|
||||
for (const payment of booking.payment) {
|
||||
await deletePayment(payment.id, credential);
|
||||
await prisma.payment.delete({
|
||||
where: {
|
||||
id: payment.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.attendee.deleteMany({
|
||||
where: {
|
||||
bookingId: booking.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.bookingReference.updateMany({
|
||||
where: {
|
||||
bookingId: booking.id,
|
||||
},
|
||||
data: { deleted: true },
|
||||
});
|
||||
|
||||
const attendeesListPromises = booking.attendees.map(async (attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
|
||||
@@ -400,7 +400,7 @@ const scheduleAIPhoneCallTask = async (args: ScheduleAIPhoneCallTaskArgs) => {
|
||||
if (userId) {
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `ai-phone-call:${userId}`,
|
||||
identifier: `executeAIPhoneCall:${userId}`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -381,4 +381,122 @@ describe("EmailWorkflowService", () => {
|
||||
expect(result.sendTo.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateParametersToBuildEmailWorkflowContent - Form Data", () => {
|
||||
const mockCommonScheduleFunctionParams = {
|
||||
triggerEvent: WorkflowTriggerEvents.FORM_SUBMITTED,
|
||||
timeSpan: {
|
||||
time: null,
|
||||
timeUnit: null,
|
||||
},
|
||||
workflowStepId: 1,
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
userId: 1,
|
||||
teamId: null,
|
||||
seatReferenceUid: undefined,
|
||||
verifiedAt: new Date(),
|
||||
creditCheckFn: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
const mockFormData = {
|
||||
responses: {
|
||||
"contact person": {
|
||||
value: "Jane Smith",
|
||||
response: "Jane Smith",
|
||||
},
|
||||
email: {
|
||||
value: "jane@example.com",
|
||||
response: "jane@example.com",
|
||||
},
|
||||
},
|
||||
routedEventTypeId: null,
|
||||
user: {
|
||||
email: "user@test.com",
|
||||
timeFormat: 12,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
test("should work with EMAIL_ADDRESS action and formData (no evt)", async () => {
|
||||
const mockWorkflowStep = {
|
||||
id: 1,
|
||||
action: WorkflowActions.EMAIL_ADDRESS,
|
||||
verifiedAt: new Date(),
|
||||
sendTo: "recipient@example.com",
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
reminderBody: null,
|
||||
emailSubject: null,
|
||||
sender: null,
|
||||
includeCalendarEvent: false,
|
||||
numberVerificationPending: false,
|
||||
numberRequired: false,
|
||||
};
|
||||
|
||||
const result = await emailWorkflowService.generateParametersToBuildEmailWorkflowContent({
|
||||
formData: mockFormData,
|
||||
workflowStep: mockWorkflowStep,
|
||||
workflow: { userId: 1 },
|
||||
commonScheduleFunctionParams: mockCommonScheduleFunctionParams,
|
||||
hideBranding: false,
|
||||
});
|
||||
|
||||
expect(result.sendTo).toEqual(["recipient@example.com"]);
|
||||
expect(result.formData).toEqual(mockFormData);
|
||||
expect(result.evt).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should work with EMAIL_ATTENDEE action and formData (no evt)", async () => {
|
||||
const mockWorkflowStep = {
|
||||
id: 1,
|
||||
action: WorkflowActions.EMAIL_ATTENDEE,
|
||||
verifiedAt: new Date(),
|
||||
sendTo: null,
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
reminderBody: null,
|
||||
emailSubject: null,
|
||||
sender: null,
|
||||
includeCalendarEvent: false,
|
||||
numberVerificationPending: false,
|
||||
numberRequired: false,
|
||||
};
|
||||
|
||||
const result = await emailWorkflowService.generateParametersToBuildEmailWorkflowContent({
|
||||
formData: mockFormData,
|
||||
workflowStep: mockWorkflowStep,
|
||||
workflow: { userId: 1 },
|
||||
commonScheduleFunctionParams: mockCommonScheduleFunctionParams,
|
||||
hideBranding: false,
|
||||
});
|
||||
|
||||
// Should extract email from formData responses
|
||||
expect(result.sendTo).toEqual(["jane@example.com"]);
|
||||
expect(result.formData).toEqual(mockFormData);
|
||||
expect(result.evt).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should throw error if neither evt nor formData is provided", async () => {
|
||||
const mockWorkflowStep = {
|
||||
id: 1,
|
||||
action: WorkflowActions.EMAIL_ADDRESS,
|
||||
verifiedAt: new Date(),
|
||||
sendTo: "recipient@example.com",
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
reminderBody: null,
|
||||
emailSubject: null,
|
||||
sender: null,
|
||||
includeCalendarEvent: false,
|
||||
numberVerificationPending: false,
|
||||
numberRequired: false,
|
||||
};
|
||||
|
||||
await expect(
|
||||
emailWorkflowService.generateParametersToBuildEmailWorkflowContent({
|
||||
workflowStep: mockWorkflowStep,
|
||||
workflow: { userId: 1 },
|
||||
commonScheduleFunctionParams: mockCommonScheduleFunctionParams,
|
||||
hideBranding: false,
|
||||
})
|
||||
).rejects.toThrow("Either evt or formData must be provided");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -208,11 +208,15 @@ export class EmailWorkflowService {
|
||||
}
|
||||
}
|
||||
|
||||
// The evt builder already validates the bookerUrl exists
|
||||
if (!evt || typeof evt.bookerUrl !== "string") {
|
||||
// Only check for bookerUrl when evt is provided (not for form submissions)
|
||||
if (evt && typeof evt.bookerUrl !== "string") {
|
||||
throw new Error("bookerUrl not a part of the evt");
|
||||
}
|
||||
|
||||
if (!evt && !formData) {
|
||||
throw new Error("Either evt or formData must be provided");
|
||||
}
|
||||
|
||||
const contextData: WorkflowContextData = evt
|
||||
? { evt: evt as BookingInfo }
|
||||
: { formData: formData as FormSubmissionData };
|
||||
@@ -311,7 +315,8 @@ export class EmailWorkflowService {
|
||||
organizerEmail: evt.organizer.email,
|
||||
sendToEmail: sendTo[0],
|
||||
});
|
||||
const meetingUrl = getVideoCallUrlFromCalEvent(evt) || bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl;
|
||||
const meetingUrl =
|
||||
getVideoCallUrlFromCalEvent(evt) || bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl;
|
||||
const variables: VariablesType = {
|
||||
eventName: evt.title || "",
|
||||
organizerName: evt.organizer.name,
|
||||
|
||||
@@ -245,6 +245,21 @@ export default function InstantEventController({
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="autoTranslateInstantMeetingTitleEnabled"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
title={t("auto_translate_instant_meeting_title")}
|
||||
checked={value}
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
data-testid="auto-translate-instant-meeting-title-toggle"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6 mt-4"
|
||||
description={t("auto_translate_instant_meeting_title_description")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<InstantMeetingWebhooks eventType={eventType} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -145,6 +145,7 @@ const commons = {
|
||||
requiresConfirmationWillBlockSlot: false,
|
||||
canSendCalVideoTranscriptionEmails: false,
|
||||
instantMeetingExpiryTimeOffsetInSeconds: 0,
|
||||
autoTranslateInstantMeetingTitleEnabled: true,
|
||||
instantMeetingScheduleId: null,
|
||||
instantMeetingParameters: [],
|
||||
eventTypeColor: null,
|
||||
|
||||
@@ -135,6 +135,7 @@ export type FormValues = {
|
||||
seatsShowAvailabilityCount: boolean | null;
|
||||
seatsPerTimeSlotEnabled: boolean;
|
||||
autoTranslateDescriptionEnabled: boolean;
|
||||
autoTranslateInstantMeetingTitleEnabled: boolean;
|
||||
fieldTranslations: EventTypeTranslation[];
|
||||
scheduleName: string;
|
||||
minimumBookingNotice: number;
|
||||
|
||||
@@ -570,6 +570,7 @@ export class EventTypeRepository {
|
||||
requiresConfirmationWillBlockSlot: true,
|
||||
requiresBookerEmailVerification: true,
|
||||
autoTranslateDescriptionEnabled: true,
|
||||
autoTranslateInstantMeetingTitleEnabled: true,
|
||||
fieldTranslations: {
|
||||
select: {
|
||||
translatedText: true,
|
||||
@@ -868,6 +869,7 @@ export class EventTypeRepository {
|
||||
requiresConfirmationWillBlockSlot: true,
|
||||
requiresBookerEmailVerification: true,
|
||||
autoTranslateDescriptionEnabled: true,
|
||||
autoTranslateInstantMeetingTitleEnabled: true,
|
||||
fieldTranslations: {
|
||||
select: {
|
||||
translatedText: true,
|
||||
|
||||
@@ -45,7 +45,7 @@ const buildResultPayload = async (
|
||||
};
|
||||
};
|
||||
|
||||
const logFailedResults = (results: PromiseSettledResult<any>[]) => {
|
||||
const logFailedResults = (results: PromiseSettledResult<unknown>[]) => {
|
||||
const failed = results.filter((x) => x.status === "rejected") as PromiseRejectedResult[];
|
||||
if (failed.length < 1) return;
|
||||
const failedMessage = failed.map((r) => r.reason);
|
||||
@@ -89,10 +89,12 @@ const handleMarkNoShow = async ({
|
||||
attendees,
|
||||
noShowHost,
|
||||
userId,
|
||||
userUuid: _userUuid,
|
||||
locale,
|
||||
platformClientParams,
|
||||
}: TNoShowInputSchema & {
|
||||
userId?: number;
|
||||
userUuid?: string;
|
||||
locale?: string;
|
||||
platformClientParams?: PlatformClientParams;
|
||||
}) => {
|
||||
|
||||
@@ -131,6 +131,7 @@ describe("createCRMEvent", () => {
|
||||
// Set up Prisma mocks with proper return values
|
||||
prismaMock.booking.findUnique.mockResolvedValueOnce(mockBooking);
|
||||
prismaMock.credential.findUnique.mockResolvedValueOnce(mockCredential);
|
||||
prismaMock.credential.findMany.mockResolvedValueOnce([mockCredential]);
|
||||
prismaMock.bookingReference.createMany.mockResolvedValueOnce({ count: 1 });
|
||||
prismaMock.bookingReference.findMany.mockResolvedValueOnce([]);
|
||||
const payload = JSON.stringify({
|
||||
@@ -198,6 +199,7 @@ describe("createCRMEvent", () => {
|
||||
};
|
||||
|
||||
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||
prismaMock.credential.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const payload = JSON.stringify({
|
||||
bookingUid: "booking-123",
|
||||
@@ -238,6 +240,7 @@ describe("createCRMEvent", () => {
|
||||
|
||||
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||
prismaMock.credential.findUnique.mockResolvedValue(null);
|
||||
prismaMock.credential.findMany.mockResolvedValueOnce([]);
|
||||
prismaMock.bookingReference.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
mockCreateEvent.mockRejectedValue(new Error("Salesforce API error"));
|
||||
@@ -288,6 +291,7 @@ describe("createCRMEvent", () => {
|
||||
|
||||
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||
prismaMock.credential.findUnique.mockResolvedValue(mockCredential);
|
||||
prismaMock.credential.findMany.mockResolvedValueOnce([mockCredential]);
|
||||
prismaMock.bookingReference.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
mockCreateEvent.mockRejectedValue(new RetryableError("Salesforce API Retryable error"));
|
||||
@@ -334,6 +338,7 @@ describe("createCRMEvent", () => {
|
||||
|
||||
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||
prismaMock.credential.findUnique.mockResolvedValue(mockCredential);
|
||||
prismaMock.credential.findMany.mockResolvedValueOnce([mockCredential]);
|
||||
prismaMock.bookingReference.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
mockCreateEvent.mockRejectedValue(new Error("Salesforce API error"));
|
||||
@@ -401,6 +406,11 @@ describe("createCRMEvent", () => {
|
||||
.mockResolvedValueOnce(mockSalesforceCredential)
|
||||
.mockResolvedValueOnce(mockHubspotCredential);
|
||||
|
||||
prismaMock.credential.findMany.mockResolvedValueOnce([
|
||||
mockSalesforceCredential,
|
||||
mockHubspotCredential,
|
||||
]);
|
||||
|
||||
prismaMock.bookingReference.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
// Throw error for first app and resolve for second app
|
||||
@@ -462,6 +472,7 @@ describe("createCRMEvent", () => {
|
||||
|
||||
prismaMock.booking.findUnique.mockResolvedValue(mockBooking);
|
||||
prismaMock.credential.findUnique.mockResolvedValueOnce(mockSalesforceCredential);
|
||||
prismaMock.credential.findMany.mockResolvedValueOnce([mockSalesforceCredential]);
|
||||
prismaMock.bookingReference.findMany.mockResolvedValueOnce([
|
||||
{ id: 1, type: "salesforce_crm", uid: "sf-event-123", credentialId: 1, bookingId: 1 },
|
||||
]);
|
||||
|
||||
@@ -111,52 +111,72 @@ export async function createCRMEvent(payload: string): Promise<void> {
|
||||
});
|
||||
|
||||
const errorPerApp: Record<AppSlug, UnknownError> = {};
|
||||
// Find enabled CRM apps for the event type
|
||||
|
||||
// Parse apps and collect credential IDs for enabled CRM apps
|
||||
const appInfoMap = new Map<string, { app: any; credentialId: number }>();
|
||||
const credentialIds = new Set<number>();
|
||||
|
||||
for (const appSlug of Object.keys(eventTypeAppMetadata)) {
|
||||
const appData = eventTypeAppMetadata[appSlug as keyof typeof eventTypeAppMetadata];
|
||||
const appDataSchema = appDataSchemas[appSlug as keyof typeof appDataSchemas];
|
||||
|
||||
if (!appData || !appDataSchema) {
|
||||
throw new Error(`Could not find appData or appDataSchema for ${appSlug}`);
|
||||
}
|
||||
|
||||
const appParse = appDataSchema.safeParse(appData);
|
||||
|
||||
if (!appParse.success) {
|
||||
log.error(`Error parsing event type app data for bookingUid ${bookingUid}`, appParse?.error);
|
||||
continue;
|
||||
}
|
||||
|
||||
const app = appParse.data;
|
||||
const hasCrmCategory =
|
||||
app.appCategories && app.appCategories.some((category: string) => category === "crm");
|
||||
|
||||
if (!app.enabled || !app.credentialId || !hasCrmCategory) {
|
||||
log.info(`Skipping CRM app ${appSlug}`, {
|
||||
enabled: app.enabled,
|
||||
credentialId: app.credentialId,
|
||||
hasCrmCategory,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
appInfoMap.set(appSlug, { app, credentialId: app.credentialId });
|
||||
credentialIds.add(app.credentialId);
|
||||
}
|
||||
|
||||
const crmCredentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: Array.from(credentialIds),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const crmCredentialMap = new Map<number, (typeof crmCredentials)[number]>();
|
||||
for (const credential of crmCredentials) {
|
||||
crmCredentialMap.set(credential.id, credential);
|
||||
}
|
||||
//Find enabled CRM apps for the event type
|
||||
for (const appSlug of Array.from(appInfoMap.keys())) {
|
||||
const { app, credentialId } = appInfoMap.get(appSlug)!;
|
||||
// Try Catch per app to ensure all apps are tried even if any of them throws an error
|
||||
// If we want to retry for an error from this try catch, then that error must be thrown as a RetryableError
|
||||
try {
|
||||
const appData = eventTypeAppMetadata[appSlug as keyof typeof eventTypeAppMetadata];
|
||||
const appDataSchema = appDataSchemas[appSlug as keyof typeof appDataSchemas];
|
||||
if (!appData || !appDataSchema) {
|
||||
throw new Error(`Could not find appData or appDataSchema for ${appSlug}`);
|
||||
}
|
||||
|
||||
const appParse = appDataSchema.safeParse(appData);
|
||||
|
||||
if (!appParse.success) {
|
||||
log.error(`Error parsing event type app data for bookingUid ${bookingUid}`, appParse?.error);
|
||||
continue;
|
||||
}
|
||||
|
||||
const app = appParse.data;
|
||||
const hasCrmCategory =
|
||||
app.appCategories && app.appCategories.some((category: string) => category === "crm");
|
||||
|
||||
if (!app.enabled || !app.credentialId || !hasCrmCategory) {
|
||||
log.info(`Skipping CRM app ${appSlug}`, {
|
||||
enabled: app.enabled,
|
||||
credentialId: app.credentialId,
|
||||
hasCrmCategory,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const crmCredential = await prisma.credential.findUnique({
|
||||
where: {
|
||||
id: app.credentialId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const crmCredential = crmCredentialMap.get(credentialId);
|
||||
|
||||
if (!crmCredential) {
|
||||
throw new Error(`Credential not found for credentialId: ${app.credentialId}`);
|
||||
throw new Error(`Credential not found for credentialId: ${credentialId}`);
|
||||
}
|
||||
|
||||
const existingBookingReferenceForTheCredential = existingBookingReferences.find(
|
||||
|
||||
@@ -174,9 +174,9 @@ export async function executeAIPhoneCall(payload: string) {
|
||||
}
|
||||
|
||||
const rateLimitIdentifier = data.teamId
|
||||
? `ai-phone-call:team:${data.teamId}`
|
||||
? `executeAIPhoneCall:team-${data.teamId}`
|
||||
: data.userId
|
||||
? `ai-phone-call:user:${data.userId}`
|
||||
? `executeAIPhoneCall:user-${data.userId}`
|
||||
: null;
|
||||
|
||||
if (!rateLimitIdentifier) {
|
||||
|
||||
@@ -31,7 +31,7 @@ export function EventScheduleItem() {
|
||||
suffixSlot={
|
||||
schedule && (
|
||||
<Link href={`/availability/${schedule.id}`} className="inline-flex">
|
||||
<Badge color="orange" size="sm" className="hidden hover:cursor-pointer group-hover:inline-flex">
|
||||
<Badge color="orange" size="sm" className="invisible hover:cursor-pointer group-hover:visible">
|
||||
{t("edit")}
|
||||
</Badge>
|
||||
</Link>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useEffect, startTransition } from "react";
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { SelectField } from "@calcom/ui/components/form";
|
||||
@@ -7,67 +8,80 @@ import { getQueryParam } from "../../bookings/Booker/utils/query-param";
|
||||
import { useTroubleshooterStore } from "../store";
|
||||
|
||||
export function EventTypeSelect() {
|
||||
const { data: eventTypes, isPending } = trpc.viewer.eventTypes.list.useQuery();
|
||||
const selectedEventType = useTroubleshooterStore((state) => state.event);
|
||||
const setSelectedEventType = useTroubleshooterStore((state) => state.setEvent);
|
||||
|
||||
const selectedEventQueryParam = getQueryParam("eventType");
|
||||
const { data: eventTypes, isPending } = trpc.viewer.eventTypes.listWithTeam.useQuery();
|
||||
const { event: selectedEventType, setEvent: setSelectedEventType } = useTroubleshooterStore(
|
||||
(state) => ({
|
||||
event: state.event,
|
||||
setEvent: state.setEvent,
|
||||
}),
|
||||
shallow
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (!eventTypes) return [];
|
||||
return eventTypes.map((e) => ({
|
||||
label: e.title,
|
||||
value: e.slug,
|
||||
value: e.id.toString(),
|
||||
id: e.id,
|
||||
duration: e.length,
|
||||
}));
|
||||
}, [eventTypes]);
|
||||
|
||||
// Initialize event type from query param or default to first event
|
||||
useEffect(() => {
|
||||
if (!selectedEventType && eventTypes && eventTypes[0] && !selectedEventQueryParam) {
|
||||
const { id, slug, length } = eventTypes[0];
|
||||
setSelectedEventType({
|
||||
id,
|
||||
slug,
|
||||
duration: length,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [eventTypes]);
|
||||
if (!eventTypes || eventTypes.length === 0) return;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedEventQueryParam) {
|
||||
// ensure that the update is deferred until the Suspense boundary has finished hydrating
|
||||
const selectedEventIdParam = getQueryParam("eventTypeId");
|
||||
const eventTypeId = selectedEventIdParam ? parseInt(selectedEventIdParam, 10) : null;
|
||||
|
||||
// If we already have a selected event that matches the query param, don't do anything
|
||||
if (selectedEventType?.id === eventTypeId) return;
|
||||
|
||||
// If there's a query param, try to find and set that event
|
||||
if (eventTypeId && !isNaN(eventTypeId)) {
|
||||
startTransition(() => {
|
||||
const foundEventType = eventTypes?.find((et) => et.slug === selectedEventQueryParam);
|
||||
const foundEventType = eventTypes.find((et) => et.id === eventTypeId);
|
||||
if (foundEventType) {
|
||||
const { id, slug, length } = foundEventType;
|
||||
setSelectedEventType({ id, slug, duration: length });
|
||||
} else if (eventTypes && eventTypes[0]) {
|
||||
const { id, slug, length } = eventTypes[0];
|
||||
setSelectedEventType({
|
||||
id,
|
||||
slug,
|
||||
duration: length,
|
||||
id: foundEventType.id,
|
||||
slug: foundEventType.slug,
|
||||
duration: foundEventType.length,
|
||||
teamId: foundEventType.team?.id ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [eventTypes, selectedEventQueryParam, setSelectedEventType]);
|
||||
|
||||
// If no event is selected and no valid query param, default to first event
|
||||
if (!selectedEventType && !eventTypeId) {
|
||||
const firstEvent = eventTypes[0];
|
||||
setSelectedEventType({
|
||||
id: firstEvent.id,
|
||||
slug: firstEvent.slug,
|
||||
duration: firstEvent.length,
|
||||
teamId: firstEvent.team?.id ?? null,
|
||||
});
|
||||
}
|
||||
}, [eventTypes, selectedEventType, setSelectedEventType]);
|
||||
|
||||
return (
|
||||
<SelectField
|
||||
label="Event Type"
|
||||
options={options}
|
||||
isDisabled={isPending || options.length === 0}
|
||||
value={options.find((option) => option.value === selectedEventType?.slug) || options[0]}
|
||||
value={options.find((option) => option.id === selectedEventType?.id) || options[0]}
|
||||
onChange={(option) => {
|
||||
if (!option) return;
|
||||
setSelectedEventType({
|
||||
slug: option.value,
|
||||
id: option.id,
|
||||
duration: option.duration,
|
||||
});
|
||||
const foundEventType = eventTypes?.find((et) => et.id === option.id);
|
||||
if (foundEventType) {
|
||||
setSelectedEventType({
|
||||
id: foundEventType.id,
|
||||
slug: foundEventType.slug,
|
||||
duration: foundEventType.length,
|
||||
teamId: foundEventType.team?.id ?? null,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
|
||||
.add(extraDays - 1, "day")
|
||||
.utc()
|
||||
.format(),
|
||||
eventTypeId: event?.id,
|
||||
withSource: true,
|
||||
},
|
||||
{
|
||||
@@ -35,13 +36,16 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
|
||||
}
|
||||
);
|
||||
|
||||
const isTeamEvent = !!event?.teamId;
|
||||
const { data: schedule } = useSchedule({
|
||||
username: session?.user.username || "",
|
||||
eventSlug: event?.slug,
|
||||
// For team events, don't pass eventSlug to avoid slug lookup issues - use eventId instead
|
||||
eventSlug: isTeamEvent ? null : event?.slug,
|
||||
eventId: event?.id,
|
||||
timezone,
|
||||
month: startDate.format("YYYY-MM"),
|
||||
orgSlug: session?.user.org?.slug,
|
||||
isTeamEvent,
|
||||
});
|
||||
|
||||
const endDate = dayjs(startDate)
|
||||
|
||||
@@ -17,6 +17,7 @@ type EventType = {
|
||||
id: number;
|
||||
slug: string;
|
||||
duration: number;
|
||||
teamId?: number | null;
|
||||
};
|
||||
|
||||
export type TroubleshooterStore = {
|
||||
@@ -76,7 +77,7 @@ export const useTroubleshooterStore = create<TroubleshooterStore>((set, get) =>
|
||||
event: null,
|
||||
setEvent: (event: EventType) => {
|
||||
set({ event });
|
||||
updateQueryParam("eventType", event.slug ?? "");
|
||||
updateQueryParam("eventTypeId", event.id.toString());
|
||||
},
|
||||
month: getQueryParam("month") || getQueryParam("date") || dayjs().format("YYYY-MM"),
|
||||
setMonth: (month: string | null) => {
|
||||
|
||||
@@ -1014,18 +1014,19 @@ export class UserRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async findUnlockedUserForSession({ userId }: { userId: number }) {
|
||||
const user = await this.prismaClient.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
// Locked users can't login
|
||||
locked: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
async findUnlockedUserForSession({ userId }: { userId: number }) {
|
||||
const user = await this.prismaClient.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
// Locked users can't login
|
||||
locked: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
uuid: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
bio: true,
|
||||
avatarUrl: true,
|
||||
|
||||
@@ -173,7 +173,11 @@ function getZapierPayload(data: WithUTCOffsetType<EventPayloadType & { createdAt
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
function applyTemplate(template: string, data: WebhookDataType, contentType: ContentType) {
|
||||
function applyTemplate(
|
||||
template: string,
|
||||
data: WebhookDataType | Record<string, unknown>,
|
||||
contentType: ContentType
|
||||
) {
|
||||
const compiled = compile(template)(data).replace(/"/g, '"');
|
||||
|
||||
if (contentType === "application/json") {
|
||||
@@ -268,15 +272,27 @@ export const sendGenericWebhookPayload = async ({
|
||||
data: Record<string, unknown>;
|
||||
rootData?: Record<string, unknown>;
|
||||
}) => {
|
||||
const body = JSON.stringify({
|
||||
const { payloadTemplate: template } = webhook;
|
||||
|
||||
const contentType =
|
||||
!template || jsonParse(template) ? "application/json" : "application/x-www-form-urlencoded";
|
||||
|
||||
const defaultPayload = {
|
||||
// Added rootData props first so that using the known(i.e. triggerEvent, createdAt, payload) properties in rootData doesn't override the known properties
|
||||
...rootData,
|
||||
triggerEvent: triggerEvent,
|
||||
createdAt: createdAt,
|
||||
payload: data,
|
||||
});
|
||||
};
|
||||
|
||||
return _sendPayload(secretKey, webhook, body, "application/json");
|
||||
let body: string;
|
||||
if (template) {
|
||||
body = applyTemplate(template, defaultPayload, contentType);
|
||||
} else {
|
||||
body = JSON.stringify(defaultPayload);
|
||||
}
|
||||
|
||||
return _sendPayload(secretKey, webhook, body, contentType);
|
||||
};
|
||||
|
||||
export const createWebhookSignature = (params: { secret?: string | null; body: string }) =>
|
||||
|
||||
@@ -13,4 +13,27 @@ export default [
|
||||
target: ".",
|
||||
message: "lib package should not import from features to avoid circular dependencies.",
|
||||
}),
|
||||
forbid({
|
||||
from: "../trpc/**",
|
||||
target: ".",
|
||||
message: "lib package should not import from trpc to avoid circular dependencies.",
|
||||
}),
|
||||
{
|
||||
// Block @trpc/server imports to keep packages/lib framework-agnostic.
|
||||
// defaultResponder.ts is temporarily excluded until it can be refactored.
|
||||
ignores: ["server/defaultResponder.ts", "server/defaultResponder.test.ts"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "@trpc/server",
|
||||
message: "packages/lib should not import from @trpc/server to keep it framework-agnostic.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { ErrorWithCode } from "@calcom/lib/errors";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
import { HttpError } from "../http-error";
|
||||
import { TracedError } from "../tracing/error";
|
||||
@@ -156,42 +154,6 @@ describe("TracedError handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TRPCError handling", () => {
|
||||
test("should handle TRPCError with BAD_REQUEST", () => {
|
||||
const trpcError = new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid input data",
|
||||
});
|
||||
|
||||
const result = getServerErrorFromUnknown(trpcError);
|
||||
|
||||
expect(result.statusCode).toBe(400);
|
||||
expect(result.message).toBe("Invalid input data");
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should handle TracedError wrapping TRPCError", () => {
|
||||
const trpcError = new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Resource not found",
|
||||
});
|
||||
const tracedData = { resourceId: "789" };
|
||||
const traceContext = {
|
||||
traceId: "trace_trpc123",
|
||||
spanId: "span_trpc123",
|
||||
operation: "resource_lookup",
|
||||
};
|
||||
|
||||
const tracedError = new TracedError(trpcError, traceContext, tracedData);
|
||||
|
||||
const result = getServerErrorFromUnknown(tracedError);
|
||||
|
||||
expect(result.statusCode).toBe(404);
|
||||
expect(result.message).toBe("Resource not found");
|
||||
expect(result.data).toEqual({ ...tracedData, traceId: traceContext.traceId });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ZodError handling", () => {
|
||||
test("should handle ZodError with validation issues", () => {
|
||||
const zodError = new ZodError([
|
||||
|
||||
@@ -5,9 +5,6 @@ import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { ErrorWithCode } from "@calcom/lib/errors";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||
|
||||
import { HttpError } from "../http-error";
|
||||
import { redactError } from "../redactError";
|
||||
import { stripeInvalidRequestErrorSchema } from "../stripe-error";
|
||||
@@ -40,8 +37,11 @@ function parseZodErrorIssues(issues: ZodIssue[]): string {
|
||||
/**
|
||||
* Converts unknown error types to HttpError with proper status code mapping and error redaction.
|
||||
* SERVER-ONLY: This function imports Prisma and Stripe schemas and should only be used in server-side code.
|
||||
* Use in API routes, tRPC handlers, webhooks, and server-side services.
|
||||
* Use in API routes, webhooks, and server-side services.
|
||||
* For client-side code, use getErrorFromUnknown from @calcom/lib/errors instead.
|
||||
*
|
||||
* NOTE: This function does NOT handle TRPCError. Callers that need to handle TRPCError should do so
|
||||
* explicitly before calling this function (see onErrorHandler.ts and defaultResponder.ts for examples).
|
||||
*/
|
||||
export function getServerErrorFromUnknown(cause: unknown): HttpError {
|
||||
let traceId: string | undefined;
|
||||
@@ -53,15 +53,6 @@ export function getServerErrorFromUnknown(cause: unknown): HttpError {
|
||||
cause = cause.originalError;
|
||||
}
|
||||
|
||||
if (cause instanceof TRPCError) {
|
||||
const statusCode = getHTTPStatusCodeFromError(cause);
|
||||
return new HttpError({
|
||||
statusCode,
|
||||
message: cause.message,
|
||||
cause,
|
||||
data: traceId ? { ...tracedData, traceId } : undefined,
|
||||
});
|
||||
}
|
||||
if (isZodError(cause)) {
|
||||
return new HttpError({
|
||||
statusCode: 400,
|
||||
|
||||
@@ -155,6 +155,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
|
||||
assignRRMembersUsingSegment: false,
|
||||
rrSegmentQueryValue: null,
|
||||
autoTranslateDescriptionEnabled: false,
|
||||
autoTranslateInstantMeetingTitleEnabled: true,
|
||||
useEventLevelSelectedCalendars: false,
|
||||
allowReschedulingPastBookings: false,
|
||||
hideOrganizerEmail: false,
|
||||
|
||||
@@ -1,4 +1,24 @@
|
||||
## 1.1.2
|
||||
## 2.0.0
|
||||
|
||||
⚠️ This version contains a breaking change ⚠️. Specifically, the tailwind upgrade under major changes. Atoms now use tailwind at version 4, which means that you have to upgrade your project to use tailwind at version 4.
|
||||
|
||||
### Major Changes
|
||||
|
||||
- [#25577](https://github.com/calcom/cal.com/pull/25577) [`a1e1bf8`](https://github.com/calcom/cal.com/commit/a1e1bf8b76da5489f91895c3a776c38dea346cee) Thanks [@supalarry](https://github.com/supalarry)! - feat: use tailwind v4 (breaking change).
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#24857](https://github.com/calcom/cal.com/pull/24857) [`c6daa61`](https://github.com/calcom/cal.com/commit/c6daa61e5efe24665f3d3dd0bea33c79dda6f797) Thanks [@ibex088](https://github.com/ibex088)! - Fix Booker atom verify email dialog infinite re-render when entering verification code
|
||||
|
||||
- [#24896](https://github.com/calcom/cal.com/pull/24896) [`957d197`](https://github.com/calcom/cal.com/commit/957d19740ed31834e8346d3fa309605a6d657c11) Thanks [@Ryukemeister](https://github.com/Ryukemeister)! - This PR introduces two variants for the calendar view atom, a generic calendar view and an event type specific view containing bookings data and busy times from connected calendars.
|
||||
|
||||
- [#25093](https://github.com/calcom/cal.com/pull/25093) [`3e7a848`](https://github.com/calcom/cal.com/commit/3e7a848769d49295ee6e4251cf4afd2390793ae2) Thanks [@ibex088](https://github.com/ibex088)! - Refactor SettingsHeader to accept onBackButtonClick callback instead of using useRouter directly.
|
||||
|
||||
- [#25204](https://github.com/calcom/cal.com/pull/25204) [`037cb8e`](https://github.com/calcom/cal.com/commit/037cb8ee2a83c1d725d5d913d47911a5997bee01) Thanks [@ThyMinimalDev](https://github.com/ThyMinimalDev)! - Add defaultPhoneCountry prop to BookerPlatformWrapper with ISO 3166-1 alpha-2 type safety
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#25418](https://github.com/calcom/cal.com/pull/25418) [`8fcbf2c`](https://github.com/calcom/cal.com/commit/8fcbf2c167ce747e302c0c03095ddcdbebc728a3) Thanks [@Ryukemeister](https://github.com/Ryukemeister)! - This PR fixes the issue of connect atoms not working inside iframes. It also updates the atoms exports to include the `useAvailableSlots` hook.
|
||||
|
||||
## 1.12.1
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ export const useEventTypeForm = ({
|
||||
})),
|
||||
seatsPerTimeSlotEnabled: eventType.seatsPerTimeSlot,
|
||||
autoTranslateDescriptionEnabled: eventType.autoTranslateDescriptionEnabled,
|
||||
autoTranslateInstantMeetingTitleEnabled: eventType.autoTranslateInstantMeetingTitleEnabled ?? true,
|
||||
rescheduleWithSameRoundRobinHost: eventType.rescheduleWithSameRoundRobinHost,
|
||||
assignAllTeamMembers: eventType.assignAllTeamMembers,
|
||||
assignRRMembersUsingSegment: eventType.assignRRMembersUsingSegment,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"description": "Customizable UI components to integrate scheduling into your product.",
|
||||
"authors": "Cal.com, Inc.",
|
||||
"version": "1.12.1",
|
||||
"version": "2.0.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/calcom/cal.com"
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."EventType" ADD COLUMN "autoTranslateInstantMeetingTitleEnabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -182,6 +182,7 @@ model EventType {
|
||||
canSendCalVideoTranscriptionEmails Boolean @default(true)
|
||||
|
||||
autoTranslateDescriptionEnabled Boolean @default(false)
|
||||
autoTranslateInstantMeetingTitleEnabled Boolean @default(true)
|
||||
/// @zod.import(["import { recurringEventType } from '../../zod-utils'"]).custom.use(recurringEventType)
|
||||
recurringEvent Json?
|
||||
disableGuests Boolean @default(false)
|
||||
|
||||
@@ -31,10 +31,10 @@ const handleSendingSMS = async ({
|
||||
// If neither is provided(Just in case), we check the rate limit for the reminderPhone.
|
||||
await checkSMSRateLimit({
|
||||
identifier: teamId
|
||||
? `handleSendingSMS:team:${teamId}`
|
||||
? `handleSendingSMS:team-${teamId}`
|
||||
: organizerUserId
|
||||
? `handleSendingSMS:user:${organizerUserId}`
|
||||
: `handleSendingSMS:user:${piiHasher.hash(reminderPhone)}`,
|
||||
? `handleSendingSMS:org-user-${organizerUserId}`
|
||||
: `handleSendingSMS:user-${piiHasher.hash(reminderPhone)}`,
|
||||
rateLimitingType: "sms",
|
||||
});
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ describe("SMSManager", () => {
|
||||
const result = await smsManager.sendSMSToAttendee(mockCalEvent.attendees[0], "test-booking-uid");
|
||||
|
||||
expect(checkSMSRateLimit).toHaveBeenCalledWith({
|
||||
identifier: "handleSendingSMS:user:1",
|
||||
identifier: "handleSendingSMS:org-user-1",
|
||||
rateLimitingType: "sms",
|
||||
});
|
||||
|
||||
|
||||
@@ -45,10 +45,10 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
|
||||
safeStringify({ user, userFromDb, upId })
|
||||
);
|
||||
|
||||
const { email, username, id } = user;
|
||||
if (!email || !id) {
|
||||
return null; // should we return null here?
|
||||
}
|
||||
const { email, username, id, uuid } = user;
|
||||
if (!email || !id) {
|
||||
return null; // should we return null here?
|
||||
}
|
||||
|
||||
const userMetaData = userMetadata.parse(user.metadata || {});
|
||||
const orgMetadata = teamMetadataSchema.parse(user.profile?.organization?.metadata || {});
|
||||
@@ -71,19 +71,20 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
|
||||
requestedSlug: orgMetadata?.requestedSlug ?? null,
|
||||
};
|
||||
|
||||
return {
|
||||
...user,
|
||||
avatar: `${WEBAPP_URL}/${user.username}/avatar.png${organization.id ? `?orgId=${organization.id}` : ""}`,
|
||||
// TODO: OrgNewSchema - later - We could consolidate the props in user.profile?.organization as organization is a profile thing now.
|
||||
organization,
|
||||
organizationId: organization.id,
|
||||
id,
|
||||
email,
|
||||
username,
|
||||
locale,
|
||||
defaultBookerLayouts: userMetaData?.defaultBookerLayouts || null,
|
||||
requiresBookerEmailVerification: user.requiresBookerEmailVerification,
|
||||
};
|
||||
return {
|
||||
...user,
|
||||
avatar: `${WEBAPP_URL}/${user.username}/avatar.png${organization.id ? `?orgId=${organization.id}` : ""}`,
|
||||
// TODO: OrgNewSchema - later - We could consolidate the props in user.profile?.organization as organization is a profile thing now.
|
||||
organization,
|
||||
organizationId: organization.id,
|
||||
id,
|
||||
uuid,
|
||||
email,
|
||||
username,
|
||||
locale,
|
||||
defaultBookerLayouts: userMetaData?.defaultBookerLayouts || null,
|
||||
requiresBookerEmailVerification: user.requiresBookerEmailVerification,
|
||||
};
|
||||
}
|
||||
|
||||
export type UserFromSession = Awaited<ReturnType<typeof getUserFromSession>>;
|
||||
|
||||
@@ -22,7 +22,7 @@ export const addSecondaryEmailHandler = async ({ ctx, input }: AddSecondaryEmail
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `addSecondaryEmail.${user.id}`,
|
||||
identifier: `addSecondaryEmail:${user.id}`,
|
||||
});
|
||||
|
||||
const existingPrimaryEmail = await prisma.user.findUnique({
|
||||
|
||||
@@ -24,7 +24,7 @@ export const resendVerifyEmail = async ({ input, ctx }: ResendEmailOptions) => {
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `resendVerifyEmail.${ctx.user.id}`,
|
||||
identifier: `resendVerifyEmail:${ctx.user.id}`,
|
||||
});
|
||||
|
||||
let emailVerified = Boolean(ctx.user.emailVerified);
|
||||
|
||||
@@ -27,7 +27,7 @@ export const sendVerifyEmailCode = async ({
|
||||
}) => {
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `emailVerifyByCode.${identifier}`,
|
||||
identifier: `sendVerifyEmailCode:${identifier}`,
|
||||
});
|
||||
|
||||
return await sendEmailVerificationByCode({
|
||||
|
||||
+9
-3
@@ -71,13 +71,19 @@ async function getTeamMembers({
|
||||
});
|
||||
|
||||
const userRepo = new UserRepository(prisma);
|
||||
const users = memberships.map((membership) => membership.user);
|
||||
const enrichedUsers = await userRepo.enrichUsersWithTheirProfileExcludingOrgMetadata(users);
|
||||
const enrichedUserMap = new Map<number, (typeof enrichedUsers)[0]>();
|
||||
enrichedUsers.forEach((enrichedUser) => {
|
||||
enrichedUserMap.set(enrichedUser.id, enrichedUser);
|
||||
});
|
||||
const membershipWithUserProfile = [];
|
||||
for (const membership of memberships) {
|
||||
const enrichedUser = enrichedUserMap.get(membership.user.id);
|
||||
if (!enrichedUser) continue;
|
||||
membershipWithUserProfile.push({
|
||||
...membership,
|
||||
user: await userRepo.enrichUserWithItsProfileExcludingOrgMetadata({
|
||||
user: membership.user,
|
||||
}),
|
||||
user: enrichedUser,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ import type { TConfirmInputSchema } from "./confirm.schema";
|
||||
|
||||
type ConfirmOptions = {
|
||||
ctx: {
|
||||
user: Pick<NonNullable<TrpcSessionUser>, "id" | "email" | "username" | "role" | "destinationCalendar">;
|
||||
user: Pick<NonNullable<TrpcSessionUser>, "id" | "uuid" | "email" | "username" | "role" | "destinationCalendar">;
|
||||
};
|
||||
input: TConfirmInputSchema;
|
||||
};
|
||||
|
||||
@@ -93,6 +93,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
aiPhoneCallConfig,
|
||||
isRRWeightsEnabled,
|
||||
autoTranslateDescriptionEnabled,
|
||||
autoTranslateInstantMeetingTitleEnabled,
|
||||
description: newDescription,
|
||||
title: newTitle,
|
||||
seatsPerTimeSlot,
|
||||
@@ -231,7 +232,8 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
|
||||
const data: Prisma.EventTypeUpdateInput = {
|
||||
...rest,
|
||||
// autoTranslate feature is allowed for org users only
|
||||
// Only update autoTranslateInstantMeetingTitleEnabled when explicitly provided to avoid overwriting saved opt-out
|
||||
...(autoTranslateInstantMeetingTitleEnabled !== undefined && { autoTranslateInstantMeetingTitleEnabled }),
|
||||
// Only set if explicitly provided to avoid overwriting existing value with false
|
||||
...(autoTranslateDescriptionEnabled !== undefined && {
|
||||
autoTranslateDescriptionEnabled: Boolean(ctx.user.organizationId && autoTranslateDescriptionEnabled),
|
||||
|
||||
@@ -11,19 +11,26 @@ type ListWithTeamOptions = {
|
||||
|
||||
export const listWithTeamHandler = async ({ ctx }: ListWithTeamOptions) => {
|
||||
const userId = ctx.user.id;
|
||||
const query = Prisma.sql`SELECT "public"."EventType"."id", "public"."EventType"."teamId", "public"."EventType"."title", "public"."EventType"."slug", "j1"."name" as "teamName"
|
||||
const query = Prisma.sql`SELECT "public"."EventType"."id", "public"."EventType"."teamId", "public"."EventType"."title", "public"."EventType"."slug", "public"."EventType"."length", "j1"."name" as "teamName"
|
||||
FROM "public"."EventType"
|
||||
LEFT JOIN "public"."Team" AS "j1" ON ("j1"."id") = ("public"."EventType"."teamId")
|
||||
WHERE "public"."EventType"."userId" = ${userId}
|
||||
UNION
|
||||
SELECT "public"."EventType"."id", "public"."EventType"."teamId", "public"."EventType"."title", "public"."EventType"."slug", "j1"."name" as "teamName"
|
||||
SELECT "public"."EventType"."id", "public"."EventType"."teamId", "public"."EventType"."title", "public"."EventType"."slug", "public"."EventType"."length", "j1"."name" as "teamName"
|
||||
FROM "public"."EventType"
|
||||
INNER JOIN "public"."Team" AS "j1" ON ("j1"."id") = ("public"."EventType"."teamId")
|
||||
INNER JOIN "public"."Membership" AS "t2" ON "t2"."teamId" = "j1"."id"
|
||||
WHERE "t2"."userId" = ${userId} AND "t2"."accepted" = true`;
|
||||
|
||||
const result = await db.$queryRaw<
|
||||
{ id: number; teamId: number | null; title: string; slug: string; teamName: string | null }[]
|
||||
{
|
||||
id: number;
|
||||
teamId: number | null;
|
||||
title: string;
|
||||
slug: string;
|
||||
length: number;
|
||||
teamName: string | null;
|
||||
}[]
|
||||
>(query);
|
||||
|
||||
return result.map((row) => ({
|
||||
@@ -31,5 +38,6 @@ export const listWithTeamHandler = async ({ ctx }: ListWithTeamOptions) => {
|
||||
team: row.teamId ? { id: row.teamId, name: row.teamName || "" } : null,
|
||||
title: row.title,
|
||||
slug: row.slug,
|
||||
length: row.length,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -48,7 +48,7 @@ export const verifyCode = async ({
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: hashEmail(email),
|
||||
identifier: `verifyCode:${hashEmail(email)}`,
|
||||
});
|
||||
|
||||
const secret = createHash("md5")
|
||||
|
||||
+2
-3
@@ -1,10 +1,9 @@
|
||||
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 type { Prisma, Team, User, Membership, Profile } from "@calcom/prisma/client";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
import type { JsonValue } from "@calcom/types/Json";
|
||||
|
||||
import inviteMemberHandler, { inviteMembersWithNoInviterPermissionCheck } from "./inviteMember.handler";
|
||||
|
||||
@@ -66,7 +65,7 @@ async function createTestTeam(data: {
|
||||
slug: string;
|
||||
isOrganization?: boolean;
|
||||
parentId?: number;
|
||||
metadata?: JsonValue;
|
||||
metadata?: Prisma.TeamCreateInput["metadata"];
|
||||
organizationSettings?: {
|
||||
orgAutoAcceptEmail: string;
|
||||
isOrganizationVerified?: boolean;
|
||||
|
||||
@@ -4523,11 +4523,11 @@ __metadata:
|
||||
posthog-node: ^5.9.5
|
||||
qrcode: ^1.5.1
|
||||
raw-body: ^2.5.1
|
||||
react: ^18.2.0
|
||||
react: 18.2.0
|
||||
react-colorful: ^5.6.0
|
||||
react-date-picker: ^8.3.6
|
||||
react-digit-input: ^2.1.0
|
||||
react-dom: ^18.2.0
|
||||
react-dom: 18.2.0
|
||||
react-easy-crop: ^3.5.2
|
||||
react-hook-form: ^7.43.3
|
||||
react-live-chat-loader: ^2.8.1
|
||||
@@ -22961,7 +22961,7 @@ __metadata:
|
||||
date-fns-tz: ^3.2.0
|
||||
dotenv-checker: ^1.1.5
|
||||
eslint: 9.36.0
|
||||
husky: ^9.1.7
|
||||
husky: 9.1.7
|
||||
i18n-unused: ^0.13.0
|
||||
jest-diff: ^29.5.0
|
||||
jest-summarizing-reporter: ^1.1.4
|
||||
@@ -31324,7 +31324,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"husky@npm:^9.1.7":
|
||||
"husky@npm:9.1.7":
|
||||
version: 9.1.7
|
||||
resolution: "husky@npm:9.1.7"
|
||||
bin:
|
||||
@@ -41582,7 +41582,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-dom@npm:^18, react-dom@npm:^18.2.0":
|
||||
"react-dom@npm:18.2.0, react-dom@npm:^18, react-dom@npm:^18.2.0":
|
||||
version: 18.2.0
|
||||
resolution: "react-dom@npm:18.2.0"
|
||||
dependencies:
|
||||
@@ -42135,14 +42135,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react@npm:19.2.0":
|
||||
version: 19.2.0
|
||||
resolution: "react@npm:19.2.0"
|
||||
checksum: 33dd01bf699e1c5040eb249e0f552519adf7ee90b98c49d702a50bf23af6852ea46023a5f7f93966ab10acd7a45428fa0f193c686ecdaa7a75a03886e53ec3fe
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react@npm:^18, react@npm:^18.2.0":
|
||||
"react@npm:18.2.0, react@npm:^18, react@npm:^18.2.0":
|
||||
version: 18.2.0
|
||||
resolution: "react@npm:18.2.0"
|
||||
dependencies:
|
||||
@@ -42151,6 +42144,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react@npm:19.2.1":
|
||||
version: 19.2.1
|
||||
resolution: "react@npm:19.2.1"
|
||||
checksum: 9b32ace5c532ed78bc5a907bfec77d966c8f117049d9bf06b627c4cebe5ef2c899f18ed283f8ed5b85346176b5ccc5328ab6fae0b389f2936a87575ce0e47670
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"read-cache@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "read-cache@npm:1.0.0"
|
||||
|
||||
Reference in New Issue
Block a user