Compare commits

...
15 Commits
Author SHA1 Message Date
GitHub Actions e1beec0487 chore: release v5.2.4
Post release / release (push) Canceled after 0s
2025-04-23 01:47:22 +00:00
Joe Au-Yeung 6bbf263981 feat: Optout of SMS workflows (#20769)
* Do not call dub if API key isn't present

* Add `WorkflowOptOutContact` table

* Add `sendTo` column to `WorkflowReminder`

* Add `workflowOptOutContactRepository`

* Add `WorkflowReminderRepository`

* Write `sendTo` number when scheduling SMS messages

* Add `p-limit` package

* Add `determineOptOutType` to `TwilioProvider`

* Add `deleteMultipleScheduledSMS`

* Create `WorkflowOptOutService`

* Add endpoint handler for Twilio SMS responses

* Add `isOptedOut` method to `workflowOptOutContactRepository`

* Verify phone number is not opted out before scheduling SMS

* Use `smsReminderNumber` instead of `sendTo`

* Type fix

* Add .env variable if opt out is available

* Add opt out message to SMS

* Import `pLimit` directly in method

* Address select comment

* Guard against undefined phone number from form

* Add early return to `deleteWorkflowReminders`

* Add request validation from Twilio

* Add fallback message as i18N string

* Only delete SMS attendee scheduled reminders
2025-04-22 22:00:19 +01:00
Benny Joo d86a28bf7f simplify metadata in /availability/[schedule] route (#20827) 2025-04-22 19:41:24 +00:00
Benny Joo c00e8cfa54 perf: Fetch data server-side for Api Keys page (#20677)
* server-side fetching for api keys page

* refactor billing page

* fix
2025-04-22 18:20:41 +00:00
Alex van Andel e82cc328d3 refactor: Remove kysely as its unused (#20817) 2025-04-22 14:53:41 -03:00
Rajiv Sahal 7dbfc1b2e0 fix: user is already part of a team (#20774)
* better error message

* redirect to login if no user session

* fixup

* remove callback url since the same function is used by org and platform both

* remove extra exclamation mark

* implement PR feedback
2025-04-22 16:07:13 +00:00
Benny Joo 02fb7c0ae0 fix: remove window usage in not-found client (#20822)
* remove window usage in not-found client

* fix
2025-04-22 16:04:36 +00:00
Eunjae Lee 9004dcb93b feat: open dropdown right after adding a filter (#20819)
* feat: open dropdown right after adding a filter

* clean up timeout
2025-04-22 15:29:56 +00:00
devin-ai-integration[bot]andeunjae@cal.com cf83b0a236 feat: update DeleteSegmentDialog to use ConfirmationDialogContent CAL-5399 (#20820)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
2025-04-22 15:24:06 +00:00
Lauris Skraucis 7f02464fed refactor: forbidden 403 v2 guard messages (#20794)
* refactor: delete unused proxy guard

* refactor: roles.guard.ts

* refactor: is-org.guard.ts

* refactor: permissions.guard.ts

* refactor: oauth-client-guard.ts

* refactor: is-user-event-type-webhook-guard.ts

* refactor: platform-plan.guard.ts

* refactor: roles.guard.ts

* refactor: cache guard result if canAccess=true

* refactor: is-admin-api-enabled.guard.ts

* refactor: is-team-in-org.guard.ts

* refactor: platform-plan.guard.ts

* refactor: is-webhook-in-org.guard.ts

* refactor: is-user-in-org.guard.ts

* refactor: is-user-webhook-guard.ts

* refactor: is-user-in-org-team.guard.ts

* refactor: is-membership-in-org.guard.ts

* refactor: is-oauth-client-webhook-guard.ts

* refactor: is-routing-form-in-team.guard.ts

* refactor: is-managed-org-in-manager-org.guard.ts

* fill the gaps

* refactor: api-auth.strategy.ts

* fix: test

* implement feedback

* fix: tests

* fix: refactor

* fix: test
2025-04-22 16:28:53 +03:00
Keith Williams 2371253cb5 chore: Add more Foundation and Consumer team CODEOWNERS configuration (#20816) 2025-04-22 09:58:13 -03:00
Morgan 859e7f1c88 fix: api v2 license check (#20813)
* fix: api v2 license check

* update cache key to avoid conflicts
2025-04-22 09:02:43 -03:00
amrit f1e8481878 refactor: remove iframe usage note from Preview tab in EmbedTabs component (#20810)
* fix: trigger lingo.dev by removing duplicate value

* refactor: remove iframe usage note from Preview tab in EmbedTabs component
2025-04-22 10:03:19 +00:00
amritandHariom Balhara acf3a7c54e feat: add "React (Atom)" to embed generator and rename rest (#20618)
* fix: trigger lingo.dev by removing duplicate value

* feat: add support for React Atom embed options

* fix: update locator for React code tab in embed code generator E2E tests

* fix: update embed tab names to clarify iframe usage

* fix failing test

* fix: indentation and installation commands for atoms

* fix: add note about iframe usage in Preview tab

* refactor: replace Cal component with BookerEmbed in EmbedCodes

* fix: Add EmbedConfig type import in Embed.tsx

- Added missing EmbedConfig type to the existing types import
- Resolves TypeScript error "Cannot find name 'EmbedConfig'"

* fix: update BookerEmbed usage in EmbedCodes component

* fix: enhance BookerEmbed implementation and update installation instructions in EmbedTabs

* fix: improve comments in EmbedCodes for clarity on layout options and styling references

* fix: streamline BookerEmbed usage in EmbedCodes with improved class handling and layout options

* fix: embed type selection

* fix: update layout selection in EmbedCodes to use dynamic configuration

* fix: update embed configuration to use theme instead of layout

* fix: conditionally render embed options based on if we are selecting atom

* fix: use simple props instead of embed specific logic to fix the active embed bug

* Fix double tabs activeness

* Revert isCurrent change

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-04-22 04:51:10 +00:00
Hariom Balhara f163e4638b fix: Convert an error in webhook to info (#20806)
* Convert error to info

* Convert error to info
2025-04-22 10:10:00 +05:30
87 changed files with 1364 additions and 1459 deletions

No files matched your search

+1
View File
@@ -187,6 +187,7 @@ TWILIO_WHATSAPP_PHONE_NUMBER=
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters) # For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
NEXT_PUBLIC_SENDER_ID= NEXT_PUBLIC_SENDER_ID=
TWILIO_VERIFY_SID= TWILIO_VERIFY_SID=
TWILIO_OPT_OUT_ENABLED=
# Set it to "1" if you need to run E2E tests locally. # Set it to "1" if you need to run E2E tests locally.
NEXT_PUBLIC_IS_E2E= NEXT_PUBLIC_IS_E2E=
+6
View File
@@ -1,6 +1,11 @@
/**/package.json @calcom/Foundation /**/package.json @calcom/Foundation
/apps/api/**/* @calcom/Platform @calcom/Foundation /apps/api/**/* @calcom/Platform @calcom/Foundation
/apps/ui-playground/**/* @calcom/Consumer
/apps/web/components/**/* @calcom/Consumer
/apps/web/modules/**/* @calcom/Consumer
/apps/web/**/layout.tsx @calcom/Foundation /apps/web/**/layout.tsx @calcom/Foundation
/apps/web/lib/**/* @calcom/Foundation
/apps/web/middleware.ts @calcom/Foundation
/deploy/**/* @calcom/Foundation /deploy/**/* @calcom/Foundation
/infra/**/* @calcom/Foundation /infra/**/* @calcom/Foundation
/packages/app-store/applecalendar/**/* @calcom/Foundation /packages/app-store/applecalendar/**/* @calcom/Foundation
@@ -22,3 +27,4 @@
/packages/platform/**/* @calcom/Platform @calcom/Foundation /packages/platform/**/* @calcom/Platform @calcom/Foundation
/packages/prisma/**/* @calcom/Foundation /packages/prisma/**/* @calcom/Foundation
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation /packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
/packages/ui/**/* @calcom/Consumer
+7
View File
@@ -0,0 +1,7 @@
undecided:
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+6
View File
@@ -107,6 +107,12 @@ $ yarn test:e2e:watch --testPathPattern=filePath
$ yarn run test:cov $ yarn run test:cov
``` ```
## Conventions
### Guards
1. In case a guard would return "false" for "canActivate" instead throw ForbiddenException with an error message containing guard name and the error.
2. In case a guard would return "false" for "canActivate" DO NOT cache the result in redis, because we don't want that someone is forbidden, updates whatever was the problem, and then has to wait for cache to expire. We only cache in redis guard results where "canAccess" is "true".
## Support ## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
@@ -8,7 +8,7 @@ export class BookingUidGuard implements CanActivate {
const bookingUid = request.params.bookingUid; const bookingUid = request.params.bookingUid;
if (!bookingUid) { if (!bookingUid) {
throw new BadRequestException("Booking UID missing in the request path"); throw new BadRequestException("BookingUidGuard - Booking UID missing in the request path");
} }
return true; return true;
+2 -1
View File
@@ -1,11 +1,12 @@
import { MeController } from "@/ee/me/me.controller"; import { MeController } from "@/ee/me/me.controller";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { TokensModule } from "@/modules/tokens/tokens.module"; import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module"; import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
@Module({ @Module({
imports: [UsersModule, SchedulesModule_2024_04_15, TokensModule], imports: [UsersModule, SchedulesModule_2024_04_15, TokensModule, OAuthClientModule],
controllers: [MeController], controllers: [MeController],
}) })
export class MeModule {} export class MeModule {}
+3 -3
View File
@@ -82,7 +82,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
} }
if (allLimitsBlocked) { if (allLimitsBlocked) {
throw new ThrottlerException("Too many requests. Please try again later."); throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");
} }
return true; return true;
@@ -96,7 +96,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response); const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
if (isBlocked) { if (isBlocked) {
throw new ThrottlerException("Too many requests. Please try again later."); throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");
} }
return true; return true;
@@ -150,7 +150,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
}); });
if (!apiKeyRecord) { if (!apiKeyRecord) {
throw new UnauthorizedException("Invalid API Key"); throw new UnauthorizedException("CustomThrottlerGuard - Invalid API Key");
} }
rateLimits = await this.dbRead.prisma.rateLimit.findMany({ rateLimits = await this.dbRead.prisma.rateLimit.findMany({
@@ -104,13 +104,6 @@ describe("PlatformPlanGuard", () => {
await expect(guard.canActivate(mockContext)).resolves.toBe(true); await expect(guard.canActivate(mockContext)).resolves.toBe(true);
}); });
it("should throw ForbiddenException if the result is cached as false in Redis", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(false));
await expect(guard.canActivate(mockContext)).rejects.toThrow(ForbiddenException);
});
function createMockExecutionContext(context: Record<string, string | object>): ExecutionContext { function createMockExecutionContext(context: Record<string, string | object>): ExecutionContext {
return createMock<ExecutionContext>({ return createMock<ExecutionContext>({
switchToHttp: () => ({ switchToHttp: () => ({
@@ -16,65 +16,63 @@ export class PlatformPlanGuard implements CanActivate {
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const teamId = request.params.teamId as string;
const orgId = request.params.orgId as string;
const user = request.user as ApiAuthGuardUser;
const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType; const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType;
const { canAccess } = await this.checkPlatformPlanAccess({ teamId, orgId, user, minimumPlan }); if (!minimumPlan) {
return canAccess; return true;
}
const request = context.switchToHttp().getRequest<Request>();
const orgId = request.params.orgId;
const user = request.user as ApiAuthGuardUser;
if (!user) {
throw new ForbiddenException("PlatformPlanGuard - No user associated with the request.");
}
if (!orgId) {
throw new ForbiddenException("PlatformPlanGuard - No organization associated with the request.");
}
return await this.checkPlatformPlanAccess(orgId, minimumPlan);
} }
async checkPlatformPlanAccess({ async checkPlatformPlanAccess(orgId: string, minimumPlan: PlatformPlanType) {
teamId, const REDIS_CACHE_KEY = `apiv2:org:${orgId}:guard:platformbilling:${minimumPlan}`;
orgId, const cachedValue = await this.redisService.redis.get(REDIS_CACHE_KEY);
user, if (cachedValue !== null) {
minimumPlan, return cachedValue === "true";
}: {
teamId?: string;
orgId?: string;
user: ApiAuthGuardUser;
minimumPlan: PlatformPlanType;
}): Promise<{ canAccess: boolean }> {
const REDIS_CACHE_KEY = `apiv2:user:${user?.id ?? "none"}:org:${orgId ?? "none"}:team:${
teamId ?? "none"
}:guard:platformbilling:${minimumPlan}`;
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
if (cachedAccess) {
return { canAccess: cachedAccess };
} }
let canAccess = false; const organization = await this.organizationsRepository.findByIdIncludeBilling(Number(orgId));
const isPlatform = organization?.isPlatform;
const hasSubscription = organization?.platformBilling?.subscriptionId;
if (user && orgId) { if (!organization) {
const team = await this.organizationsRepository.findByIdIncludeBilling(Number(orgId)); throw new ForbiddenException(`PlatformPlanGuard - No organization found with id=${orgId}.`);
const isPlatform = team?.isPlatform; }
const hasSubscription = team?.platformBilling?.subscriptionId; if (!isPlatform) {
await this.redisService.redis.set(REDIS_CACHE_KEY, "true", "EX", 300);
if (!team) { return true;
canAccess = false; }
} else if (!isPlatform) { if (!hasSubscription) {
canAccess = true; throw new ForbiddenException(
} else if (!hasSubscription) { `PlatformPlanGuard - No platform subscription found for organization with id=${orgId}.`
canAccess = false; );
} else { }
canAccess = hasMinimumPlan({ if (
currentPlan: team.platformBilling?.plan as PlatformPlanType, !hasMinimumPlan({
minimumPlan: minimumPlan, currentPlan: organization.platformBilling?.plan as PlatformPlanType,
plans: ["FREE", "STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"], minimumPlan: minimumPlan,
}); plans: ["FREE", "STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
} })
) {
throw new ForbiddenException(
`PlatformPlanGuard - organization with id=${orgId} does not have required plan for this operation. Minimum plan is ${minimumPlan} while the organization has ${
organization.platformBilling?.plan || "undefined"
}.`
);
} }
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300); await this.redisService.redis.set(REDIS_CACHE_KEY, "true", "EX", 300);
if (canAccess) { return true;
return { canAccess };
}
throw new ForbiddenException(
`Platform plan - you do not have required plan for this operation. Minimum plan is ${minimumPlan}.`
);
} }
} }
@@ -90,7 +88,7 @@ export function hasMinimumPlan(props: HasMinimumPlanProp): boolean {
if (currentPlanIndex === -1 || minimumPlanIndex === -1) { if (currentPlanIndex === -1 || minimumPlanIndex === -1) {
throw new Error( throw new Error(
`Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}` `PlatformPlanGuard - Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}`
); );
} }
@@ -20,11 +20,11 @@ export class IsMembershipInOrg implements CanActivate {
const orgId: string = request.params.orgId; const orgId: string = request.params.orgId;
if (!orgId) { if (!orgId) {
throw new ForbiddenException("No org id found in request params."); throw new ForbiddenException("IsMembershipInOrg - No org id found in request params.");
} }
if (!membershipId) { if (!membershipId) {
throw new ForbiddenException("No membership id found in request params."); throw new ForbiddenException("IsMembershipInOrg - No membership id found in request params.");
} }
const membership = await this.organizationsMembershipRepository.findOrgMembership( const membership = await this.organizationsMembershipRepository.findOrgMembership(
@@ -33,7 +33,7 @@ export class IsMembershipInOrg implements CanActivate {
); );
if (!membership) { if (!membership) {
throw new NotFoundException(`Membership (${membershipId}) not found.`); throw new NotFoundException(`IsMembershipInOrg - Membership (${membershipId}) not found.`);
} }
request.membership = membership; request.membership = membership;
@@ -4,7 +4,7 @@ import { NO_AUTH_PROVIDED_MESSAGE } from "@/modules/auth/strategies/api-auth/api
export class OptionalApiAuthGuard extends ApiAuthGuard { export class OptionalApiAuthGuard extends ApiAuthGuard {
handleRequest(error: Error, user: any) { handleRequest(error: Error, user: any) {
// note(Lauris): optional means that auth is not required but if it is invalid then still throw error. // note(Lauris): optional means that auth is not required but if it is invalid then still throw error.
const noAuthProvided = error && error.message === NO_AUTH_PROVIDED_MESSAGE; const noAuthProvided = error && error.message.includes(NO_AUTH_PROVIDED_MESSAGE);
if (user || noAuthProvided || !error) { if (user || noAuthProvided || !error) {
return user || null; return user || null;
} else { } else {
@@ -22,14 +22,19 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
const organizationId: string = request.params.orgId; const organizationId: string = request.params.orgId;
if (!organizationId) { if (!organizationId) {
throw new ForbiddenException("No organization id found in request params."); throw new ForbiddenException("IsAdminAPIEnabledGuard - No organization id found in request params.");
} }
const { canAccess, organization } = await this.checkAdminAPIEnabled(organizationId); const { canAccess, organization } = await this.checkAdminAPIEnabled(organizationId);
if (organization) { if (organization) {
request.organization = organization; request.organization = organization;
} }
return canAccess; if (!canAccess) {
throw new ForbiddenException(
`IsAdminAPIEnabledGuard - Organization with id=${organizationId} does not have Admin API access. Please contact https://cal.com/sales to upgrade.`
);
}
return true;
} }
async checkAdminAPIEnabled( async checkAdminAPIEnabled(
@@ -57,18 +62,21 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
); );
if (!adminAPIAccessIsEnabledInOrg) { if (!adminAPIAccessIsEnabledInOrg) {
throw new ForbiddenException( throw new ForbiddenException(
`Organization does not have Admin API access, please contact https://cal.com/sales to upgrade` `IsAdminAPIEnabledGuard - Organization does not have Admin API access, please contact https://cal.com/sales to upgrade`
); );
} }
} }
canAccess = true; canAccess = true;
org &&
(await this.redisService.redis.set( if (org && canAccess) {
await this.redisService.redis.set(
REDIS_CACHE_KEY, REDIS_CACHE_KEY,
JSON.stringify({ org: org, canAccess } satisfies CachedData), JSON.stringify({ org: org, canAccess } satisfies CachedData),
"EX", "EX",
300 300
)); );
}
return { canAccess, organization: org }; return { canAccess, organization: org };
} }
} }
@@ -1,11 +1,5 @@
import { ManagedOrganizationsRepository } from "@/modules/organizations/organizations/managed-organizations.repository"; import { ManagedOrganizationsRepository } from "@/modules/organizations/organizations/managed-organizations.repository";
import { import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
NotFoundException,
} from "@nestjs/common";
import { Request } from "express"; import { Request } from "express";
import { Team } from "@calcom/prisma/client"; import { Team } from "@calcom/prisma/client";
@@ -20,11 +14,11 @@ export class IsManagedOrgInManagerOrg implements CanActivate {
const managerOrgId: string = request.params.orgId; const managerOrgId: string = request.params.orgId;
if (!managerOrgId) { if (!managerOrgId) {
throw new ForbiddenException("No manager org id found in request params."); throw new ForbiddenException("IsManagedOrgInManagerOrg - No manager org id found in request params.");
} }
if (!managedOrgId) { if (!managedOrgId) {
throw new ForbiddenException("No managed org id found in request params."); throw new ForbiddenException("IsManagedOrgInManagerOrg - No managed org id found in request params.");
} }
const managedOrganization = await this.managedOrganizationsRepository.getByManagerManagedIds( const managedOrganization = await this.managedOrganizationsRepository.getByManagerManagedIds(
@@ -33,8 +27,8 @@ export class IsManagedOrgInManagerOrg implements CanActivate {
); );
if (!managedOrganization) { if (!managedOrganization) {
throw new NotFoundException( throw new ForbiddenException(
`Managed organization with id=${managedOrgId} within manager organization with id=${managerOrgId} not found.` `IsManagedOrgInManagerOrg - Managed organization with id=${managedOrgId} is not owned by manager organization with id=${managerOrgId}.`
); );
} }
@@ -22,7 +22,7 @@ export class IsOrgGuard implements CanActivate {
const organizationId: string = request.params.orgId; const organizationId: string = request.params.orgId;
if (!organizationId) { if (!organizationId) {
throw new ForbiddenException("No organization id found in request params."); throw new ForbiddenException("IsOrgGuard - No organization id found in request params.");
} }
const { canAccess, org } = await this.checkOrgAccess(organizationId); const { canAccess, org } = await this.checkOrgAccess(organizationId);
@@ -31,7 +31,13 @@ export class IsOrgGuard implements CanActivate {
request.organization = org; request.organization = org;
} }
return canAccess; if (!canAccess) {
throw new ForbiddenException(
`IsOrgGuard - provided organization id=${organizationId} does not represent any existing organization.`
);
}
return true;
} }
async checkOrgAccess(organizationId: string): Promise<{ canAccess: boolean; org?: Team | null }> { async checkOrgAccess(organizationId: string): Promise<{ canAccess: boolean; org?: Team | null }> {
@@ -55,7 +61,7 @@ export class IsOrgGuard implements CanActivate {
canAccess = true; canAccess = true;
} }
if (org) { if (org && canAccess) {
await this.redisService.redis.set( await this.redisService.redis.set(
REDIS_CACHE_KEY, REDIS_CACHE_KEY,
JSON.stringify({ org, canAccess } satisfies CachedData), JSON.stringify({ org, canAccess } satisfies CachedData),
@@ -26,10 +26,10 @@ export class IsWebhookInOrg implements CanActivate {
const organizationId: string = request.params.orgId; const organizationId: string = request.params.orgId;
if (!organizationId) { if (!organizationId) {
throw new ForbiddenException("No organization id found in request params."); throw new ForbiddenException("IsWebhookInOrg - No organization id found in request params.");
} }
if (!webhookId) { if (!webhookId) {
throw new ForbiddenException("No webhook id found in request params."); throw new ForbiddenException("IsWebhookInOrg - No webhook id found in request params.");
} }
const REDIS_CACHE_KEY = `apiv2:org:${webhookId}:guard:isWebhookInOrg`; const REDIS_CACHE_KEY = `apiv2:org:${webhookId}:guard:isWebhookInOrg`;
@@ -53,7 +53,7 @@ export class IsWebhookInOrg implements CanActivate {
if (isWebhookInOrg) canAccess = true; if (isWebhookInOrg) canAccess = true;
} }
if (org) { if (org && canAccess) {
await this.redisService.redis.set( await this.redisService.redis.set(
REDIS_CACHE_KEY, REDIS_CACHE_KEY,
JSON.stringify({ org: org, canAccess } satisfies CachedData), JSON.stringify({ org: org, canAccess } satisfies CachedData),
@@ -62,6 +62,12 @@ export class IsWebhookInOrg implements CanActivate {
); );
} }
return canAccess; if (!canAccess) {
throw new ForbiddenException(
`IsWebhookInOrg - webhook with id=${webhookId} is not part of the organization with id=${organizationId}.`
);
}
return true;
} }
} }
@@ -1,4 +1,5 @@
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { createMock } from "@golevelup/ts-jest"; import { createMock } from "@golevelup/ts-jest";
import { ExecutionContext } from "@nestjs/common"; import { ExecutionContext } from "@nestjs/common";
@@ -28,7 +29,21 @@ describe("PermissionsGuard", () => {
} }
}), }),
}), }),
createMock<OAuthClientRepository>() createMock<OAuthClientRepository>(),
createMock<OAuthClientsOutputService>({
transformOAuthClientPermission: jest.fn().mockImplementation((permission: number) => {
switch (permission) {
case SCHEDULE_WRITE:
return "SCHEDULE_WRITE";
case SCHEDULE_READ:
return "SCHEDULE_READ";
case APPS_WRITE:
return "APPS_WRITE";
default:
return "UNKNOWN";
}
}),
})
); );
}); });
@@ -36,13 +51,22 @@ describe("PermissionsGuard", () => {
expect(guard).toBeDefined(); expect(guard).toBeDefined();
}); });
function getMockOAuthClient(permissions: number) {
return {
id: "100",
permissions,
};
}
describe("when access token is missing", () => { describe("when access token is missing", () => {
it("should return false", async () => { it("should return false", async () => {
const mockContext = createMockExecutionContext({}); const mockContext = createMockExecutionContext({});
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]); jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(0); jest.spyOn(guard, "getOAuthClientByAccessToken").mockResolvedValue(getMockOAuthClient(0));
await expect(guard.canActivate(mockContext)).resolves.toBe(false); await expect(guard.canActivate(mockContext)).rejects.toThrow(
"PermissionsGuard - no authentication provided. Provide either authorization bearer token containing managed user access token or oAuth client id in 'x-cal-client-id' header."
);
}); });
}); });
@@ -53,7 +77,9 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0; let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE; oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions); jest
.spyOn(guard, "getOAuthClientByAccessToken")
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
await expect(guard.canActivate(mockContext)).resolves.toBe(true); await expect(guard.canActivate(mockContext)).resolves.toBe(true);
}); });
@@ -64,7 +90,9 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0; let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE; oAuthClientPermissions |= SCHEDULE_WRITE;
oAuthClientPermissions |= SCHEDULE_READ; oAuthClientPermissions |= SCHEDULE_READ;
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions); jest
.spyOn(guard, "getOAuthClientByAccessToken")
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
await expect(guard.canActivate(mockContext)).resolves.toBe(true); await expect(guard.canActivate(mockContext)).resolves.toBe(true);
}); });
@@ -75,7 +103,9 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0; let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE; oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions); jest
.spyOn(guard, "getOAuthClientByAccessToken")
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
await expect(guard.canActivate(mockContext)).resolves.toBe(true); await expect(guard.canActivate(mockContext)).resolves.toBe(true);
}); });
@@ -85,9 +115,13 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0; let oAuthClientPermissions = 0;
oAuthClientPermissions |= APPS_WRITE; oAuthClientPermissions |= APPS_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions); jest
.spyOn(guard, "getOAuthClientByAccessToken")
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
await expect(guard.canActivate(mockContext)).resolves.toBe(false); await expect(guard.canActivate(mockContext)).rejects.toThrow(
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE"
);
}); });
it("should return false for a missing permission", async () => { it("should return false for a missing permission", async () => {
@@ -96,9 +130,13 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0; let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE; oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions); jest
.spyOn(guard, "getOAuthClientByAccessToken")
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
await expect(guard.canActivate(mockContext)).resolves.toBe(false); await expect(guard.canActivate(mockContext)).rejects.toThrow(
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE, SCHEDULE_READ"
);
}); });
}); });
@@ -109,7 +147,7 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0; let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE; oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions); jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
await expect(guard.canActivate(mockContext)).resolves.toBe(true); await expect(guard.canActivate(mockContext)).resolves.toBe(true);
}); });
@@ -120,7 +158,7 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0; let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE; oAuthClientPermissions |= SCHEDULE_WRITE;
oAuthClientPermissions |= SCHEDULE_READ; oAuthClientPermissions |= SCHEDULE_READ;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions); jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
await expect(guard.canActivate(mockContext)).resolves.toBe(true); await expect(guard.canActivate(mockContext)).resolves.toBe(true);
}); });
@@ -131,7 +169,7 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0; let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE; oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions); jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
await expect(guard.canActivate(mockContext)).resolves.toBe(true); await expect(guard.canActivate(mockContext)).resolves.toBe(true);
}); });
@@ -141,9 +179,11 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0; let oAuthClientPermissions = 0;
oAuthClientPermissions |= APPS_WRITE; oAuthClientPermissions |= APPS_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions); jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
await expect(guard.canActivate(mockContext)).resolves.toBe(false); await expect(guard.canActivate(mockContext)).rejects.toThrow(
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE"
);
}); });
it("should return false for a missing permission", async () => { it("should return false for a missing permission", async () => {
@@ -152,9 +192,11 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0; let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE; oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions); jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
await expect(guard.canActivate(mockContext)).resolves.toBe(false); await expect(guard.canActivate(mockContext)).rejects.toThrow(
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE, SCHEDULE_READ"
);
}); });
}); });
@@ -1,14 +1,16 @@
import { isApiKey } from "@/lib/api-key"; import { isApiKey } from "@/lib/api-key";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common"; import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Reflector } from "@nestjs/core"; import { Reflector } from "@nestjs/core";
import { getToken } from "next-auth/jwt"; import { getToken } from "next-auth/jwt";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants"; import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
import { hasPermissions } from "@calcom/platform-utils"; import { hasPermissions } from "@calcom/platform-utils";
import { PlatformOAuthClient } from "@calcom/prisma/client";
@Injectable() @Injectable()
export class PermissionsGuard implements CanActivate { export class PermissionsGuard implements CanActivate {
@@ -16,7 +18,8 @@ export class PermissionsGuard implements CanActivate {
private reflector: Reflector, private reflector: Reflector,
private tokensRepository: TokensRepository, private tokensRepository: TokensRepository,
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly oAuthClientRepository: OAuthClientRepository private readonly oAuthClientRepository: OAuthClientRepository,
private readonly oAuthClientsOutputService: OAuthClientsOutputService
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -27,42 +30,61 @@ export class PermissionsGuard implements CanActivate {
} }
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const authString = request.get("Authorization")?.replace("Bearer ", ""); const bearerToken = request.get("Authorization")?.replace("Bearer ", "");
const nextAuthSecret = this.config.get("next.authSecret", { infer: true }); const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret }); const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID); const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID);
const apiKey = bearerToken && isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_");
if (nextAuthToken) { // only check permissions for accessTokens attached to an oAuth Client or oAuth credentials, not for next token or api key
if (nextAuthToken || apiKey) {
return true; return true;
} }
if (!authString && !oAuthClientId) { if (!bearerToken && !oAuthClientId) {
return false; throw new ForbiddenException(
"PermissionsGuard - no authentication provided. Provide either authorization bearer token containing managed user access token or oAuth client id in 'x-cal-client-id' header."
);
} }
// only check permissions for accessTokens attached to an oAuth Client const oAuthClient = bearerToken
if (isApiKey(authString, this.config.get("api.apiKeyPrefix") ?? "cal_")) { ? await this.getOAuthClientByAccessToken(bearerToken)
return true; : await this.getOAuthClientById(oAuthClientId);
const hasRequiredPermissions = hasPermissions(oAuthClient.permissions, [...requiredPermissions]);
if (!hasRequiredPermissions) {
throw new ForbiddenException(
`PermissionsGuard - oAuth client with id=${
oAuthClient.id
} does not have the required permissions=${requiredPermissions
.map((permission) => this.oAuthClientsOutputService.transformOAuthClientPermission(permission))
.join(
", "
)}. Go to platform dashboard settings and add the required permissions to the oAuth client.`
);
} }
const oAuthClientPermissions = authString return true;
? await this.getOAuthClientPermissionsByAccessToken(authString)
: await this.getOAuthClientPermissionsById(oAuthClientId);
if (!oAuthClientPermissions) {
return false;
}
return hasPermissions(oAuthClientPermissions, [...requiredPermissions]);
} }
async getOAuthClientPermissionsByAccessToken(accessToken: string) { async getOAuthClientByAccessToken(
accessToken: string
): Promise<Pick<PlatformOAuthClient, "id" | "permissions">> {
const oAuthClient = await this.tokensRepository.getAccessTokenClient(accessToken); const oAuthClient = await this.tokensRepository.getAccessTokenClient(accessToken);
return oAuthClient?.permissions; if (!oAuthClient) {
throw new ForbiddenException(
`PermissionsGuard - no oAuth client found for access token=${accessToken}`
);
}
return oAuthClient;
} }
async getOAuthClientPermissionsById(id: string) { async getOAuthClientById(id: string): Promise<Pick<PlatformOAuthClient, "id" | "permissions">> {
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(id); const oAuthClient = await this.oAuthClientRepository.getOAuthClient(id);
return oAuthClient?.permissions; if (!oAuthClient) {
throw new ForbiddenException(`PermissionsGuard - no oAuth client found for client id=${id}`);
}
return oAuthClient;
} }
} }
@@ -25,8 +25,27 @@ export class RolesGuard implements CanActivate {
const user = request.user as ApiAuthGuardUser; const user = request.user as ApiAuthGuardUser;
const allowedRole = this.reflector.get(Roles, context.getHandler()); const allowedRole = this.reflector.get(Roles, context.getHandler());
const { canAccess } = await this.checkUserRoleAccess(user, orgId, teamId, allowedRole); const { canAccess } = await this.checkUserRoleAccess(user, orgId, teamId, allowedRole);
return canAccess;
if (!canAccess) {
this.throwForbiddenError(user, orgId, teamId, allowedRole);
}
return true;
} }
throwForbiddenError(user: ApiAuthGuardUser, orgId: string, teamId: string, allowedRole: string) {
let errorMessage = `RolesGuard - user with id=${user.id} does not have the minimum required role=${allowedRole} within`;
if (orgId) {
errorMessage += ` organization with id=${orgId}`;
}
if (teamId) {
errorMessage += ` team with id=${teamId}`;
}
errorMessage += `.`;
throw new ForbiddenException(errorMessage);
}
async checkUserRoleAccess( async checkUserRoleAccess(
user: ApiAuthGuardUser, user: ApiAuthGuardUser,
orgId: string, orgId: string,
@@ -67,7 +86,9 @@ export class RolesGuard implements CanActivate {
const membership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id); const membership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
if (!membership) { if (!membership) {
this.logger.log(`User (${user.id}) is not a member of the organization (${orgId}), denying access.`); this.logger.log(`User (${user.id}) is not a member of the organization (${orgId}), denying access.`);
throw new ForbiddenException(`User is not a member of the organization.`); throw new ForbiddenException(
`RolesGuard - User is not a member of the organization with id=${orgId}.`
);
} }
if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) { if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
@@ -84,7 +105,7 @@ export class RolesGuard implements CanActivate {
const membership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id); const membership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
if (!membership) { if (!membership) {
this.logger.log(`User (${user.id}) is not a member of the team (${teamId}), denying access.`); this.logger.log(`User (${user.id}) is not a member of the team (${teamId}), denying access.`);
throw new ForbiddenException(`User is not a member of the team.`); throw new ForbiddenException(`RolesGuard - User is not a member of the team with id=${teamId}.`);
} }
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) { if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
canAccess = hasMinimumRole({ canAccess = hasMinimumRole({
@@ -102,7 +123,7 @@ export class RolesGuard implements CanActivate {
if (!orgMembership) { if (!orgMembership) {
this.logger.log(`User (${user.id}) is not part of the organization (${orgId}), denying access.`); this.logger.log(`User (${user.id}) is not part of the organization (${orgId}), denying access.`);
throw new ForbiddenException(`User is not part of the organization.`); throw new ForbiddenException(`RolesGuard - User is not part of the organization with id=${orgId}.`);
} }
// if the role checked is a TEAM role // if the role checked is a TEAM role
@@ -116,7 +137,7 @@ export class RolesGuard implements CanActivate {
`User (${user.id}) is not part of the team (${teamId}) and/or, is not an admin nor an owner of the organization (${orgId}).` `User (${user.id}) is not part of the team (${teamId}) and/or, is not an admin nor an owner of the organization (${orgId}).`
); );
throw new ForbiddenException( throw new ForbiddenException(
"User is not part of the team and/or, is not an admin nor an owner of the organization." `RolesGuard - User is not part of the team with id=${teamId} and/or, is not an admin nor an owner of the organization with id=${orgId}.`
); );
} }
@@ -138,7 +159,11 @@ export class RolesGuard implements CanActivate {
}); });
} }
} }
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
if (canAccess) {
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
}
return { canAccess }; return { canAccess };
} }
} }
@@ -165,7 +190,7 @@ export function hasMinimumRole(props: HasMinimumRoleProp): boolean {
// minimum role given does not exist // minimum role given does not exist
if (checkedRoleIndex === -1 || requiredRoleIndex === -1) { if (checkedRoleIndex === -1 || requiredRoleIndex === -1) {
throw new Error("Invalid role"); throw new Error("RolesGuard - Invalid role");
} }
return checkedRoleIndex <= requiredRoleIndex; return checkedRoleIndex <= requiredRoleIndex;
@@ -1,11 +1,5 @@
import { RoutingFormsRepository } from "@/modules/routing-forms/routing-forms.repository"; import { RoutingFormsRepository } from "@/modules/routing-forms/routing-forms.repository";
import { import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
NotFoundException,
} from "@nestjs/common";
import { Request } from "express"; import { Request } from "express";
import { Team } from "@calcom/prisma/client"; import { Team } from "@calcom/prisma/client";
@@ -20,18 +14,18 @@ export class IsRoutingFormInTeam implements CanActivate {
const routingFormId: string = request.params.routingFormId; const routingFormId: string = request.params.routingFormId;
if (!routingFormId) { if (!routingFormId) {
throw new ForbiddenException("No routing form id found in request params."); throw new ForbiddenException("IsRoutingFormInTeam - No routing form id found in request params.");
} }
if (!teamId) { if (!teamId) {
throw new ForbiddenException("No team id found in request params."); throw new ForbiddenException("IsRoutingFormInTeam - No team id found in request params.");
} }
const routingForm = await this.routingFormsRepository.getTeamRoutingForm(Number(teamId), routingFormId); const routingForm = await this.routingFormsRepository.getTeamRoutingForm(Number(teamId), routingFormId);
if (!routingForm) { if (!routingForm) {
throw new NotFoundException( throw new ForbiddenException(
`Team with id=(${teamId}) routing form with id=(${routingFormId}) not found.` `IsRoutingFormInTeam - team with id=(${teamId}) does not own routing form with id=(${routingFormId}).`
); );
} }
@@ -20,26 +20,30 @@ export class IsTeamInOrg implements CanActivate {
const orgId: string = request.params.orgId; const orgId: string = request.params.orgId;
if (!orgId) { if (!orgId) {
throw new ForbiddenException("No org id found in request params."); throw new ForbiddenException("IsTeamInOrg - No org id found in request params.");
} }
if (!teamId) { if (!teamId) {
throw new ForbiddenException("No team id found in request params."); throw new ForbiddenException("IsTeamInOrg - No team id found in request params.");
} }
const { canAccess, team } = await this.checkIfTeamIsInOrg(orgId, teamId); const { canAccess, team } = await this.checkIfTeamIsInOrg(orgId, teamId);
if (canAccess && team) { if (!canAccess) {
request.team = team; throw new ForbiddenException(
`IsTeamInOrg - Team with id=${teamId} is not part of the organization with id=${orgId}.`
);
} }
return canAccess;
request.team = team;
return true;
} }
async checkIfTeamIsInOrg(orgId: string, teamId: string): Promise<{ canAccess: boolean; team?: Team }> { async checkIfTeamIsInOrg(orgId: string, teamId: string): Promise<{ canAccess: boolean; team?: Team }> {
const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId)); const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId));
if (!team) { if (!team) {
throw new NotFoundException(`Team (${teamId}) not found.`); throw new NotFoundException(`IsTeamInOrg - Team (${teamId}) not found.`);
} }
if (!team.isOrganization && team.parentId === Number(orgId)) { if (!team.isOrganization && team.parentId === Number(orgId)) {
@@ -15,15 +15,15 @@ export class IsUserInOrgTeam implements CanActivate {
const userId: string = request.params.userId; const userId: string = request.params.userId;
if (!userId) { if (!userId) {
throw new ForbiddenException("No user id found in request params."); throw new ForbiddenException("IsUserInOrgTeam - No user id found in request params.");
} }
if (!orgId) { if (!orgId) {
throw new ForbiddenException("No org id found in request params."); throw new ForbiddenException("IsUserInOrgTeam - No org id found in request params.");
} }
if (!teamId) { if (!teamId) {
throw new ForbiddenException("No team id found in request params."); throw new ForbiddenException("IsUserInOrgTeam - No team id found in request params.");
} }
const user = await this.organizationsRepository.findOrgTeamUser( const user = await this.organizationsRepository.findOrgTeamUser(
@@ -37,6 +37,8 @@ export class IsUserInOrgTeam implements CanActivate {
return true; return true;
} }
return false; throw new ForbiddenException(
`IsUserInOrgTeam - user with id=(${userId}) is not part of the team with id=(${teamId}) in the organization with id=(${orgId})`
);
} }
} }
@@ -14,20 +14,22 @@ export class IsUserInOrg implements CanActivate {
const userId: string = request.params.userId; const userId: string = request.params.userId;
if (!userId) { if (!userId) {
throw new ForbiddenException("No user id found in request params."); throw new ForbiddenException("IsUserInOrg - No user id found in request params.");
} }
if (!orgId) { if (!orgId) {
throw new ForbiddenException("No org id found in request params."); throw new ForbiddenException("IsUserInOrg - No org id found in request params.");
} }
const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(userId)); const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(userId));
if (user) { if (!user) {
request.user = user; throw new ForbiddenException(
return true; `IsUserInOrg - user with id=${userId} is not part of the organization with id=${orgId}.`
);
} }
return false; request.user = user;
return true;
} }
} }
@@ -70,13 +70,15 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
return await this.authenticateNextAuth(nextAuthToken, request); return await this.authenticateNextAuth(nextAuthToken, request);
} }
throw new UnauthorizedException(NO_AUTH_PROVIDED_MESSAGE); throw new UnauthorizedException(`ApiAuthStrategy - ${NO_AUTH_PROVIDED_MESSAGE}`);
} catch (err) { } catch (err) {
if (err instanceof Error) { if (err instanceof Error) {
return this.error(err); return this.error(err);
} }
return this.error( return this.error(
new InternalServerErrorException("An error occurred while authenticating the request") new InternalServerErrorException(
"ApiAuthStrategy - An error occurred while authenticating the request"
)
); );
} }
} }
@@ -106,23 +108,29 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
const client = await this.oauthRepository.getOAuthClient(oAuthClientId); const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
if (!client) { if (!client) {
throw new UnauthorizedException(`Client with ID ${oAuthClientId} not found`); throw new UnauthorizedException(
`ApiAuthStrategy - oAuth client - Client with ID ${oAuthClientId} not found`
);
} }
if (client.secret !== oAuthClientSecret) { if (client.secret !== oAuthClientSecret) {
throw new UnauthorizedException("Invalid client secret"); throw new UnauthorizedException("ApiAuthStrategy - oAuth client - Invalid client secret");
} }
const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId); const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId);
if (!platformCreatorId) { if (!platformCreatorId) {
throw new UnauthorizedException("No owner ID found for this OAuth client"); throw new UnauthorizedException(
"ApiAuthStrategy - oAuth client - No owner ID found for this OAuth client"
);
} }
const user = await this.userRepository.findByIdWithProfile(platformCreatorId); const user = await this.userRepository.findByIdWithProfile(platformCreatorId);
if (!user) { if (!user) {
throw new UnauthorizedException("No user associated with the provided OAuth client"); throw new UnauthorizedException(
"ApiAuthStrategy - oAuth client - No user associated with the provided OAuth client"
);
} }
request.organizationId = client.organizationId; request.organizationId = client.organizationId;
@@ -141,7 +149,11 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
: await this.accessTokenStrategy(authString, request, requestOrigin); : await this.accessTokenStrategy(authString, request, requestOrigin);
if (!user) { if (!user) {
return this.error(new UnauthorizedException("No user associated with the provided token")); return this.error(
new UnauthorizedException(
"ApiAuthStrategy - bearer token - No user associated with the provided token"
)
);
} }
return this.success(this.getSuccessUser(user)); return this.success(this.getSuccessUser(user));
@@ -158,24 +170,26 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
async apiKeyStrategy(apiKey: string, request: ApiAuthGuardRequest) { async apiKeyStrategy(apiKey: string, request: ApiAuthGuardRequest) {
const isLicenseValid = await this.deploymentsService.checkLicense(); const isLicenseValid = await this.deploymentsService.checkLicense();
if (!isLicenseValid) { if (!isLicenseValid) {
throw new UnauthorizedException("Invalid or missing CALCOM_LICENSE_KEY environment variable"); throw new UnauthorizedException(
"ApiAuthStrategy - api key - Invalid or missing CALCOM_LICENSE_KEY environment variable"
);
} }
const strippedApiKey = stripApiKey(apiKey, this.config.get<string>("api.keyPrefix")); const strippedApiKey = stripApiKey(apiKey, this.config.get<string>("api.keyPrefix"));
const apiKeyHash = hashAPIKey(strippedApiKey); const apiKeyHash = hashAPIKey(strippedApiKey);
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash); const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
if (!keyData) { if (!keyData) {
throw new UnauthorizedException("Your api key is not valid"); throw new UnauthorizedException("ApiAuthStrategy - api key - Your api key is not valid");
} }
const isKeyExpired = const isKeyExpired =
keyData.expiresAt && new Date().setHours(0, 0, 0, 0) > keyData.expiresAt.setHours(0, 0, 0, 0); keyData.expiresAt && new Date().setHours(0, 0, 0, 0) > keyData.expiresAt.setHours(0, 0, 0, 0);
if (isKeyExpired) { if (isKeyExpired) {
throw new UnauthorizedException("Your api key is expired"); throw new UnauthorizedException("ApiAuthStrategy - api key - Your api key is expired");
} }
const apiKeyOwnerId = keyData.userId; const apiKeyOwnerId = keyData.userId;
if (!apiKeyOwnerId) { if (!apiKeyOwnerId) {
throw new UnauthorizedException("No user tied to this apiKey"); throw new UnauthorizedException("ApiAuthStrategy - api key - No user tied to this apiKey");
} }
const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(apiKeyOwnerId); const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(apiKeyOwnerId);
@@ -187,29 +201,35 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
async accessTokenStrategy(accessToken: string, request: ApiAuthGuardRequest, origin?: string) { async accessTokenStrategy(accessToken: string, request: ApiAuthGuardRequest, origin?: string) {
const accessTokenValid = await this.oauthFlowService.validateAccessToken(accessToken); const accessTokenValid = await this.oauthFlowService.validateAccessToken(accessToken);
if (!accessTokenValid) { if (!accessTokenValid) {
throw new UnauthorizedException(INVALID_ACCESS_TOKEN); throw new UnauthorizedException(`ApiAuthStrategy - access token - ${INVALID_ACCESS_TOKEN}`);
} }
const client = await this.tokensRepository.getAccessTokenClient(accessToken); const client = await this.tokensRepository.getAccessTokenClient(accessToken);
if (!client) { if (!client) {
throw new UnauthorizedException("OAuth client not found given the access token"); throw new UnauthorizedException(
"ApiAuthStrategy - access token - OAuth client not found given the access token"
);
} }
if (origin && !isOriginAllowed(origin, client.redirectUris)) { if (origin && !isOriginAllowed(origin, client.redirectUris)) {
throw new UnauthorizedException( throw new UnauthorizedException(
`Invalid request origin - please open https://app.cal.com/settings/platform and add the origin '${origin}' to the 'Redirect uris' of your OAuth client with ID '${client.id}'` `ApiAuthStrategy - access token - Invalid request origin - please open https://app.cal.com/settings/platform and add the origin '${origin}' to the 'Redirect uris' of your OAuth client with ID '${client.id}'`
); );
} }
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken); const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
if (!ownerId) { if (!ownerId) {
throw new UnauthorizedException(INVALID_ACCESS_TOKEN); throw new UnauthorizedException(
`ApiAuthStrategy - access token - ${INVALID_ACCESS_TOKEN}. No owner found for this access token.`
);
} }
const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(ownerId); const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(ownerId);
if (!user) { if (!user) {
throw new UnauthorizedException("User associated with the authentication api key not found."); throw new UnauthorizedException(
"ApiAuthStrategy - access token - User associated with the access token not found."
);
} }
const organizationId = this.usersService.getUserMainOrgId(user) as number; const organizationId = this.usersService.getUserMainOrgId(user) as number;
@@ -220,12 +240,16 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
async nextAuthStrategy(token: { email?: string | null }, request: ApiAuthGuardRequest) { async nextAuthStrategy(token: { email?: string | null }, request: ApiAuthGuardRequest) {
if (!token.email) { if (!token.email) {
throw new UnauthorizedException("Email not found in the authentication token."); throw new UnauthorizedException(
"ApiAuthStrategy - next auth - Email not found in the authentication token."
);
} }
const user = await this.userRepository.findByEmailWithProfile(token.email); const user = await this.userRepository.findByEmailWithProfile(token.email);
if (!user) { if (!user) {
throw new UnauthorizedException("User associated with the authentication token email not found."); throw new UnauthorizedException(
"ApiAuthStrategy - next auth - User associated with the authentication token email not found."
);
} }
const organizationId = this.usersService.getUserMainOrgId(user) as number; const organizationId = this.usersService.getUserMainOrgId(user) as number;
request.organizationId = organizationId; request.organizationId = organizationId;
@@ -18,23 +18,27 @@ export class NextAuthStrategy extends PassportStrategy(NextAuthPassportStrategy,
const payload = await getToken({ req, secret: nextAuthSecret }); const payload = await getToken({ req, secret: nextAuthSecret });
if (!payload) { if (!payload) {
throw new UnauthorizedException("Authentication token is missing or invalid."); throw new UnauthorizedException("NextAuthStrategy - Authentication token is missing or invalid.");
} }
if (!payload.email) { if (!payload.email) {
throw new UnauthorizedException("Email not found in the authentication token."); throw new UnauthorizedException("NextAuthStrategy - Email not found in the authentication token.");
} }
const user = await this.userRepository.findByEmailWithProfile(payload.email); const user = await this.userRepository.findByEmailWithProfile(payload.email);
if (!user) { if (!user) {
throw new UnauthorizedException("User associated with the authentication token email not found."); throw new UnauthorizedException(
"NextAuthStrategy - User associated with the authentication token email not found."
);
} }
return this.success(user); return this.success(user);
} catch (error) { } catch (error) {
if (error instanceof Error) return this.error(error); if (error instanceof Error) return this.error(error);
return this.error( return this.error(
new InternalServerErrorException("An error occurred while authenticating the request") new InternalServerErrorException(
"NextAuthStrategy - An error occurred while authenticating the request"
)
); );
} }
} }
@@ -5,10 +5,10 @@ import { ConfigService } from "@nestjs/config";
const CACHING_TIME = 86400000; // 24 hours in milliseconds const CACHING_TIME = 86400000; // 24 hours in milliseconds
const getLicenseCacheKey = (key: string) => `api-v2-license-key-url-${key}`; const getLicenseCacheKey = (key: string) => `api-v2-license-key-goblin-url-${key}`;
type LicenseCheckResponse = { type LicenseCheckResponse = {
valid: boolean; status: boolean;
}; };
@Injectable() @Injectable()
export class DeploymentsService { export class DeploymentsService {
@@ -36,12 +36,12 @@ export class DeploymentsService {
const licenseKeyUrl = this.configService.get("api.licenseKeyUrl") + `/${licenseKey}`; const licenseKeyUrl = this.configService.get("api.licenseKeyUrl") + `/${licenseKey}`;
const cachedData = await this.redisService.redis.get(getLicenseCacheKey(licenseKey)); const cachedData = await this.redisService.redis.get(getLicenseCacheKey(licenseKey));
if (cachedData) { if (cachedData) {
return (JSON.parse(cachedData) as LicenseCheckResponse)?.valid; return (JSON.parse(cachedData) as LicenseCheckResponse)?.status;
} }
const response = await fetch(licenseKeyUrl, { mode: "cors" }); const response = await fetch(licenseKeyUrl, { mode: "cors" });
const data = (await response.json()) as LicenseCheckResponse; const data = (await response.json()) as LicenseCheckResponse;
const cacheKey = getLicenseCacheKey(licenseKey); const cacheKey = getLicenseCacheKey(licenseKey);
this.redisService.redis.set(cacheKey, JSON.stringify(data), "EX", CACHING_TIME); this.redisService.redis.set(cacheKey, JSON.stringify(data), "EX", CACHING_TIME);
return data.valid; return data.status;
} }
} }
@@ -9,7 +9,6 @@ import {
ForbiddenException, ForbiddenException,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { Request } from "express";
@Injectable() @Injectable()
export class OAuthClientGuard implements CanActivate { export class OAuthClientGuard implements CanActivate {
@@ -35,7 +34,13 @@ export class OAuthClientGuard implements CanActivate {
throw new NotFoundException("OAuthClientGuard - OAuth client not found."); throw new NotFoundException("OAuthClientGuard - OAuth client not found.");
} }
return Boolean(user.isSystemAdmin || oAuthClient.organizationId === organizationId); const allowed = Boolean(user.isSystemAdmin || oAuthClient.organizationId === organizationId);
if (!allowed) {
throw new ForbiddenException(
`OAuthClientGuard - forbidden. oAuth client with id=${oAuthClientId} does not belong to the organization with id=${organizationId}.`
);
}
return true;
} }
getOrganizationId(context: ExecutionContext) { getOrganizationId(context: ExecutionContext) {
@@ -62,6 +62,6 @@ import { JwtService } from "@nestjs/jwt";
OAuthClientUsersOutputService, OAuthClientUsersOutputService,
], ],
controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController], controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController],
exports: [OAuthClientRepository, OAuthClientUsersOutputService], exports: [OAuthClientRepository, OAuthClientsOutputService, OAuthClientUsersOutputService],
}) })
export class OAuthClientModule {} export class OAuthClientModule {}
@@ -6,17 +6,8 @@ import { PlatformOAuthClientDto } from "@calcom/platform-types";
@Injectable() @Injectable()
export class OAuthClientsOutputService { export class OAuthClientsOutputService {
transformPermissions(permissionsNumber: number): Array<keyof typeof PERMISSION_MAP> { transformOAuthClients(clients: PlatformOAuthClient[]): PlatformOAuthClientDto[] {
const permissionNumbers = PERMISSIONS.filter( return clients.map((client) => this.transformOAuthClient(client));
(permission) => (permissionsNumber & permission) === permission
);
return permissionNumbers.map(
(permission) =>
Object.entries(PERMISSION_MAP).find(
([_, value]) => value === permission
)?.[0] as keyof typeof PERMISSION_MAP
);
} }
transformOAuthClient(client: PlatformOAuthClient): PlatformOAuthClientDto { transformOAuthClient(client: PlatformOAuthClient): PlatformOAuthClientDto {
@@ -24,7 +15,7 @@ export class OAuthClientsOutputService {
id: client.id, id: client.id,
name: client.name, name: client.name,
secret: client.secret, secret: client.secret,
permissions: this.transformPermissions(client.permissions), permissions: this.transformOAuthClientPermissions(client.permissions),
logo: client.logo, logo: client.logo,
redirectUris: client.redirectUris, redirectUris: client.redirectUris,
organizationId: client.organizationId, organizationId: client.organizationId,
@@ -37,7 +28,15 @@ export class OAuthClientsOutputService {
}; };
} }
transformOAuthClients(clients: PlatformOAuthClient[]): PlatformOAuthClientDto[] { transformOAuthClientPermissions(permissions: number): Array<keyof typeof PERMISSION_MAP> {
return clients.map((client) => this.transformOAuthClient(client)); const permissionsNumbers = PERMISSIONS.filter((permission) => (permissions & permission) === permission);
return permissionsNumbers.map((permission) => this.transformOAuthClientPermission(permission));
}
transformOAuthClientPermission(permission: (typeof PERMISSIONS)[number]): keyof typeof PERMISSION_MAP {
return Object.entries(PERMISSION_MAP).find(
([_, value]) => value === permission
)?.[0] as keyof typeof PERMISSION_MAP;
} }
} }
@@ -1,10 +0,0 @@
import { Injectable } from "@nestjs/common";
import { ThrottlerGuard } from "@nestjs/throttler";
@Injectable()
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
// TODO: adapt if required for CF / AWS / FlightControl proxying.
protected async getTracker(req: Record<string, any>): Promise<string> {
return req.ips.length ? req.ips[0] : req.ip;
}
}
@@ -32,21 +32,25 @@ export class IsOAuthClientWebhookGuard implements CanActivate {
const organizationId = this.usersService.getUserMainOrgId(user); const organizationId = this.usersService.getUserMainOrgId(user);
if (!user) { if (!user) {
throw new ForbiddenException("User not authenticated"); throw new ForbiddenException("IsOAuthClientWebhookGuard - User not authenticated");
} }
if (!webhookId) { if (!webhookId) {
throw new BadRequestException("webhookId parameter not specified in the request"); throw new BadRequestException(
"IsOAuthClientWebhookGuard - webhookId parameter not specified in the request"
);
} }
if (!oAuthClientId) { if (!oAuthClientId) {
throw new BadRequestException("oAuthClientId parameter not specified in the request"); throw new BadRequestException(
"IsOAuthClientWebhookGuard - oAuthClientId parameter not specified in the request"
);
} }
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(oAuthClientId); const oAuthClient = await this.oAuthClientRepository.getOAuthClient(oAuthClientId);
if (!oAuthClient) { if (!oAuthClient) {
throw new NotFoundException(`OAuthClient (${oAuthClientId}) not found`); throw new NotFoundException(`IsOAuthClientWebhookGuard - OAuthClient (${oAuthClientId}) not found`);
} }
const webhook = await this.webhooksService.getWebhookById(webhookId); const webhook = await this.webhooksService.getWebhookById(webhookId);
@@ -56,7 +60,7 @@ export class IsOAuthClientWebhookGuard implements CanActivate {
} }
if (webhook.platformOAuthClientId !== oAuthClientId) { if (webhook.platformOAuthClientId !== oAuthClientId) {
throw new ForbiddenException("Webhook does not belong to this oAuthClient"); throw new ForbiddenException("IsOAuthClientWebhookGuard - Webhook does not belong to this oAuthClient");
} }
request.webhook = webhook; request.webhook = webhook;
@@ -28,16 +28,18 @@ export class IsUserEventTypeWebhookGuard implements CanActivate {
const eventTypeId = request.params.eventTypeId; const eventTypeId = request.params.eventTypeId;
if (!user) { if (!user) {
return false; throw new ForbiddenException("IsUserEventTypeWebhookGuard - No user associated with the request.");
} }
if (eventTypeId) { if (eventTypeId) {
const eventType = await this.eventtypesRepository.getEventTypeById(parseInt(eventTypeId)); const eventType = await this.eventtypesRepository.getEventTypeById(parseInt(eventTypeId));
if (!eventType) { if (!eventType) {
throw new NotFoundException(`Event type (${eventTypeId}) not found`); throw new NotFoundException(`IsUserEventTypeWebhookGuard - Event type (${eventTypeId}) not found`);
} }
if (eventType.userId !== user.id) { if (eventType.userId !== user.id) {
throw new ForbiddenException(`User (${user.id}) is not the owner of event type (${eventTypeId})`); throw new ForbiddenException(
`IsUserEventTypeWebhookGuard - User (${user.id}) is not the owner of event type (${eventTypeId})`
);
} }
request.eventType = eventType; request.eventType = eventType;
} }
@@ -45,11 +47,13 @@ export class IsUserEventTypeWebhookGuard implements CanActivate {
if (webhookId) { if (webhookId) {
const webhook = await this.webhooksService.getWebhookById(webhookId); const webhook = await this.webhooksService.getWebhookById(webhookId);
if (!webhook.eventTypeId) { if (!webhook.eventTypeId) {
throw new BadRequestException(`Webhook (${webhookId}) is not associated with an event type`); throw new BadRequestException(
`IsUserEventTypeWebhookGuard - Webhook (${webhookId}) is not associated with an event type`
);
} }
if (webhook.eventTypeId !== parseInt(eventTypeId)) { if (webhook.eventTypeId !== parseInt(eventTypeId)) {
throw new ForbiddenException( throw new ForbiddenException(
`Webhook (${webhookId}) is not associated with event type (${eventTypeId})` `IsUserEventTypeWebhookGuard - Webhook (${webhookId}) is not associated with event type (${eventTypeId})`
); );
} }
request.webhook = webhook; request.webhook = webhook;
@@ -1,6 +1,6 @@
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service"; import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
import { Request } from "express"; import { Request } from "express";
import { Webhook } from "@calcom/prisma/client"; import { Webhook } from "@calcom/prisma/client";
@@ -14,18 +14,23 @@ export class IsUserWebhookGuard implements CanActivate {
const user = request.user as ApiAuthGuardUser; const user = request.user as ApiAuthGuardUser;
const webhookId = request.params.webhookId; const webhookId = request.params.webhookId;
if (!user || !webhookId) { if (!user) {
return false; throw new ForbiddenException("IsUserWebhookGuard - No user associated with the request.");
}
if (!webhookId) {
throw new ForbiddenException("IsUserWebhookGuard - No webhook id found in request params.");
} }
const webhook = await this.webhooksService.getWebhookById(webhookId); const webhook = await this.webhooksService.getWebhookById(webhookId);
if (webhook.userId !== user.id) { if (webhook.userId !== user.id && !user.isSystemAdmin) {
return user.isSystemAdmin; throw new ForbiddenException(
`IsUserWebhookGuard - user with id=(${user.id}) is not the owner of webhook with id=(${webhookId})`
);
} }
request.webhook = webhook; request.webhook = webhook;
return true; return true;
} }
} }
+54 -55
View File
@@ -13337,7 +13337,6 @@
}, },
"enabledLayouts": { "enabledLayouts": {
"type": "array", "type": "array",
"description": "Array of valid layouts - month, week or column",
"items": { "items": {
"type": "string", "type": "string",
"enum": [ "enum": [
@@ -21104,20 +21103,10 @@
"description": "The start time of the booking in ISO 8601 format in UTC timezone.", "description": "The start time of the booking in ISO 8601 format in UTC timezone.",
"example": "2024-08-13T09:00:00Z" "example": "2024-08-13T09:00:00Z"
}, },
"attendee": { "lengthInMinutes": {
"description": "The attendee's details.", "type": "number",
"allOf": [ "example": 30,
{ "description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
"$ref": "#/components/schemas/Attendee"
}
]
},
"bookingFieldsResponses": {
"type": "object",
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values for custom booking fields added by you.",
"example": {
"customField": "customValue"
}
}, },
"eventTypeId": { "eventTypeId": {
"type": "number", "type": "number",
@@ -21144,6 +21133,14 @@
"description": "The organization slug. Optional, only used when booking with eventTypeSlug + username or eventTypeSlug + teamSlug.", "description": "The organization slug. Optional, only used when booking with eventTypeSlug + username or eventTypeSlug + teamSlug.",
"example": "acme-corp" "example": "acme-corp"
}, },
"attendee": {
"description": "The attendee's details.",
"allOf": [
{
"$ref": "#/components/schemas/Attendee"
}
]
},
"guests": { "guests": {
"description": "An optional list of guest emails attending the event.", "description": "An optional list of guest emails attending the event.",
"example": [ "example": [
@@ -21197,10 +21194,12 @@
"key": "value" "key": "value"
} }
}, },
"lengthInMinutes": { "bookingFieldsResponses": {
"type": "number", "type": "object",
"example": 30, "description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking." "example": {
"customField": "customValue"
}
} }
}, },
"required": [ "required": [
@@ -21216,20 +21215,10 @@
"description": "The start time of the booking in ISO 8601 format in UTC timezone.", "description": "The start time of the booking in ISO 8601 format in UTC timezone.",
"example": "2024-08-13T09:00:00Z" "example": "2024-08-13T09:00:00Z"
}, },
"attendee": { "lengthInMinutes": {
"description": "The attendee's details.", "type": "number",
"allOf": [ "example": 30,
{ "description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
"$ref": "#/components/schemas/Attendee"
}
]
},
"bookingFieldsResponses": {
"type": "object",
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values for custom booking fields added by you.",
"example": {
"customField": "customValue"
}
}, },
"eventTypeId": { "eventTypeId": {
"type": "number", "type": "number",
@@ -21256,6 +21245,14 @@
"description": "The organization slug. Optional, only used when booking with eventTypeSlug + username or eventTypeSlug + teamSlug.", "description": "The organization slug. Optional, only used when booking with eventTypeSlug + username or eventTypeSlug + teamSlug.",
"example": "acme-corp" "example": "acme-corp"
}, },
"attendee": {
"description": "The attendee's details.",
"allOf": [
{
"$ref": "#/components/schemas/Attendee"
}
]
},
"guests": { "guests": {
"description": "An optional list of guest emails attending the event.", "description": "An optional list of guest emails attending the event.",
"example": [ "example": [
@@ -21309,10 +21306,12 @@
"key": "value" "key": "value"
} }
}, },
"lengthInMinutes": { "bookingFieldsResponses": {
"type": "number", "type": "object",
"example": 30, "description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking." "example": {
"customField": "customValue"
}
}, },
"instant": { "instant": {
"type": "boolean", "type": "boolean",
@@ -21334,20 +21333,10 @@
"description": "The start time of the booking in ISO 8601 format in UTC timezone.", "description": "The start time of the booking in ISO 8601 format in UTC timezone.",
"example": "2024-08-13T09:00:00Z" "example": "2024-08-13T09:00:00Z"
}, },
"attendee": { "lengthInMinutes": {
"description": "The attendee's details.", "type": "number",
"allOf": [ "example": 30,
{ "description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
"$ref": "#/components/schemas/Attendee"
}
]
},
"bookingFieldsResponses": {
"type": "object",
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values for custom booking fields added by you.",
"example": {
"customField": "customValue"
}
}, },
"eventTypeId": { "eventTypeId": {
"type": "number", "type": "number",
@@ -21374,6 +21363,14 @@
"description": "The organization slug. Optional, only used when booking with eventTypeSlug + username or eventTypeSlug + teamSlug.", "description": "The organization slug. Optional, only used when booking with eventTypeSlug + username or eventTypeSlug + teamSlug.",
"example": "acme-corp" "example": "acme-corp"
}, },
"attendee": {
"description": "The attendee's details.",
"allOf": [
{
"$ref": "#/components/schemas/Attendee"
}
]
},
"guests": { "guests": {
"description": "An optional list of guest emails attending the event.", "description": "An optional list of guest emails attending the event.",
"example": [ "example": [
@@ -21427,10 +21424,12 @@
"key": "value" "key": "value"
} }
}, },
"lengthInMinutes": { "bookingFieldsResponses": {
"type": "number", "type": "object",
"example": 30, "description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking." "example": {
"customField": "customValue"
}
}, },
"recurrenceCount": { "recurrenceCount": {
"type": "number", "type": "number",
@@ -1,16 +1,8 @@
import type { PageProps } from "app/_types"; import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils"; import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { cache } from "react";
import { z } from "zod"; import { z } from "zod";
// import { cookies, headers } from "next/headers";
// import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
// import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { ScheduleRepository } from "@calcom/lib/server/repository/schedule";
// import { TravelScheduleRepository } from "@calcom/lib/server/repository/travelSchedule";
// import { UserRepository } from "@calcom/lib/server/repository/user";
import { AvailabilitySettingsWebWrapper } from "~/availability/[schedule]/schedule-view"; import { AvailabilitySettingsWebWrapper } from "~/availability/[schedule]/schedule-view";
const querySchema = z.object({ const querySchema = z.object({
@@ -22,26 +14,13 @@ const querySchema = z.object({
.transform((val) => Number(val)), .transform((val) => Number(val)),
}); });
const getSchedule = cache((id: number) => ScheduleRepository.findScheduleById({ id })); export const generateMetadata = async () => {
export const generateMetadata = async ({ params }: PageProps) => {
const parsed = querySchema.safeParse(await params);
if (!parsed.success) {
notFound();
}
const schedule = await getSchedule(parsed.data.schedule);
if (!schedule) {
notFound();
}
return await _generateMetadata( return await _generateMetadata(
(t) => (schedule.name ? `${schedule.name} | ${t("availability")}` : t("availability")), (t) => t("availability"),
() => "", () => "",
undefined, undefined,
undefined, undefined,
`/availability/${parsed.data.schedule}` "/availability"
); );
}; };
@@ -50,47 +29,8 @@ const Page = async ({ params }: PageProps) => {
if (!parsed.success) { if (!parsed.success) {
notFound(); notFound();
} }
// const scheduleId = Number(await params.schedule);
// const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); return <AvailabilitySettingsWebWrapper />;
// const userId = session?.user?.id;
// if (!userId) {
// notFound();
// }
// let userData, schedule, travelSchedules;
// try {
// userData = await UserRepository.getTimeZoneAndDefaultScheduleId({
// userId,
// });
// if (!userData?.timeZone || !userData?.defaultScheduleId) {
// throw new Error("timeZone and defaultScheduleId not found");
// }
// } catch (e) {
// notFound();
// }
// try {
// schedule = await ScheduleRepository.findDetailedScheduleById({
// scheduleId,
// isManagedEventType: false,
// userId,
// timeZone: userData.timeZone,
// defaultScheduleId: userData.defaultScheduleId,
// });
// } catch (e) {}
// try {
// travelSchedules = await TravelScheduleRepository.findTravelSchedulesByUserId(userId);
// } catch (e) {}
return (
<AvailabilitySettingsWebWrapper
// scheduleFetched={schedule} travelSchedules={travelSchedules}
/>
);
}; };
export default Page; export default Page;
@@ -0,0 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
export async function revalidateApiKeysList() {
revalidatePath("/settings/developer/api-keys");
}
@@ -0,0 +1,5 @@
import { SkeletonLoader } from "~/settings/developer/api-keys-skeleton";
export default function Loading() {
return <SkeletonLoader />;
}
@@ -1,9 +1,10 @@
import { getTranslate, _generateMetadata } from "app/_utils"; import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { APP_NAME } from "@calcom/lib/constants"; import { APP_NAME } from "@calcom/lib/constants";
import { apiKeysRouter } from "@calcom/trpc/server/routers/viewer/apiKeys/_router";
import ApiKeysView, { NewApiKeyButton } from "~/settings/developer/api-keys-view"; import ApiKeysView from "~/settings/developer/api-keys-view";
export const generateMetadata = async () => export const generateMetadata = async () =>
await _generateMetadata( await _generateMetadata(
@@ -15,17 +16,9 @@ export const generateMetadata = async () =>
); );
const Page = async () => { const Page = async () => {
const t = await getTranslate(); const caller = await createRouterCaller(apiKeysRouter);
const apiKeys = await caller.list();
return ( return <ApiKeysView apiKeys={apiKeys} />;
<SettingsHeader
title={t("api_keys")}
description={t("create_first_api_key_description", { appName: APP_NAME })}
CTA={<NewApiKeyButton />}
borderInShellHeader={true}>
<ApiKeysView />
</SettingsHeader>
);
}; };
export default Page; export default Page;
@@ -1,4 +1,11 @@
import { _generateMetadata } from "app/_utils"; import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import ManagedUsersView from "~/settings/platform/managed-users/managed-users-view"; import ManagedUsersView from "~/settings/platform/managed-users/managed-users-view";
@@ -11,7 +18,14 @@ export const generateMetadata = async () =>
"/settings/platform/managed-users" "/settings/platform/managed-users"
); );
const ServerPageWrapper = () => { const ServerPageWrapper = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const callbackUrl = `${WEBAPP_URL}/settings/platform/managed-users`;
if (!session?.user) {
redirect(`/login?callbackUrl=${callbackUrl}`);
}
return <ManagedUsersView />; return <ManagedUsersView />;
}; };
@@ -1,19 +1,15 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils"; import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired"; import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { buildLegacyCtx } from "@lib/buildLegacyCtx"; import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import LegacyPage, { LayoutWrapper } from "~/settings/platform/new/create-new-view"; import LegacyPage, { LayoutWrapper } from "~/settings/platform/new/create-new-view";
type Props = {
isOrg: boolean;
};
export const generateMetadata = async () => export const generateMetadata = async () =>
await _generateMetadata( await _generateMetadata(
(t) => t("set_up_your_platform_organization"), (t) => t("set_up_your_platform_organization"),
@@ -23,10 +19,14 @@ export const generateMetadata = async () =>
"/settings/platform/new" "/settings/platform/new"
); );
const getData = withAppDirSsr<Props>(getServerSideProps); const ServerPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const callbackUrl = `${WEBAPP_URL}/settings/platform/new`;
if (!session?.user) {
redirect(`/login?callbackUrl=${callbackUrl}`);
}
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
return ( return (
<LayoutWrapper> <LayoutWrapper>
<LicenseRequired> <LicenseRequired>
@@ -1,4 +1,11 @@
import { _generateMetadata } from "app/_utils"; import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import PlatformPlansView from "~/settings/platform/plans/platform-plans-view"; import PlatformPlansView from "~/settings/platform/plans/platform-plans-view";
@@ -12,7 +19,14 @@ export const generateMetadata = async () => {
); );
}; };
const ServerPageWrapper = () => { const ServerPageWrapper = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const callbackUrl = `${WEBAPP_URL}/settings/platform/plans`;
if (!session?.user) {
redirect(`/login?callbackUrl=${callbackUrl}`);
}
return <PlatformPlansView />; return <PlatformPlansView />;
}; };
+1 -1
View File
@@ -6,8 +6,8 @@ import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema"; import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { userMetadata } from "@calcom/prisma/zod-utils"; import { userMetadata } from "@calcom/prisma/zod-utils";
import { CardComponent } from "@lib/plain/card-components"; import { CardComponent } from "@lib/plain/card-components";
@@ -0,0 +1,5 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import handleSMSResponse from "@calcom/features/ee/workflows/api/handleSMSResponse";
export const POST = defaultResponderForAppDir(handleSMSResponse);
+3 -1
View File
@@ -22,9 +22,11 @@ export const generateMetadata = async () => {
const ServerPage = async () => { const ServerPage = async () => {
const h = await headers(); const h = await headers();
const nonce = h.get("x-nonce") ?? undefined; const nonce = h.get("x-nonce") ?? undefined;
const host = h.get("x-forwarded-host") ?? "";
return ( return (
<PageWrapper requiresLicense={false} nonce={nonce}> <PageWrapper requiresLicense={false} nonce={nonce}>
<NotFound /> <NotFound host={host} />
</PageWrapper> </PageWrapper>
); );
}; };
+1 -3
View File
@@ -47,11 +47,9 @@ function getPageInfo(pathname: string, host: string) {
} }
} }
export function NotFound() { export function NotFound({ host }: { host: string }) {
const { t } = useLocale(); const { t } = useLocale();
const pathname = usePathname() ?? ""; const pathname = usePathname() ?? "";
const host = window.location.hostname;
const { username, pageType, url } = getPageInfo(pathname, host); const { username, pageType, url } = getPageInfo(pathname, host);
const isBookingSuccessPage = pathname?.startsWith("/booking"); const isBookingSuccessPage = pathname?.startsWith("/booking");
const isSubpage = pathname?.includes("/", 2) || isBookingSuccessPage; const isSubpage = pathname?.includes("/", 2) || isBookingSuccessPage;
+2
View File
@@ -20,6 +20,7 @@ export const POST_METHODS_ALLOWED_API_ROUTES = ["/api/auth/signup", "/api/trpc/"
export const POST_METHODS_ALLOWED_APP_ROUTES = [ export const POST_METHODS_ALLOWED_APP_ROUTES = [
"/settings/my-account/general", "/settings/my-account/general",
"/settings/developer/webhooks", "/settings/developer/webhooks",
"/settings/developer/api-keys",
]; ];
export function checkPostMethod(req: NextRequest) { export function checkPostMethod(req: NextRequest) {
const pathname = req.nextUrl.pathname; const pathname = req.nextUrl.pathname;
@@ -192,6 +193,7 @@ export const config = {
// Routes allowed for POST method (matching `POST_METHODS_ALLOWED_APP_ROUTES` array) // Routes allowed for POST method (matching `POST_METHODS_ALLOWED_APP_ROUTES` array)
"/settings/my-account/general", "/settings/my-account/general",
"/settings/developer/webhooks", "/settings/developer/webhooks",
"/settings/developer/api-keys",
], ],
}; };
@@ -0,0 +1,24 @@
"use client";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SkeletonText, SkeletonContainer } from "@calcom/ui/components/skeleton";
export const SkeletonLoader = () => {
const { t } = useLocale();
return (
<SettingsHeader
title={t("api_keys")}
description={t("create_first_api_key_description", { appName: APP_NAME })}
borderInShellHeader={true}>
<SkeletonContainer>
<div className="divide-subtle border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
</SkeletonContainer>
</SettingsHeader>
);
};
@@ -7,24 +7,13 @@ import LicenseRequired from "@calcom/ee/common/components/LicenseRequired";
import { Dialog } from "@calcom/features/components/controlled-dialog"; import { Dialog } from "@calcom/features/components/controlled-dialog";
import ApiKeyDialogForm from "@calcom/features/ee/api-keys/components/ApiKeyDialogForm"; import ApiKeyDialogForm from "@calcom/features/ee/api-keys/components/ApiKeyDialogForm";
import ApiKeyListItem from "@calcom/features/ee/api-keys/components/ApiKeyListItem"; import ApiKeyListItem from "@calcom/features/ee/api-keys/components/ApiKeyListItem";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { APP_NAME } from "@calcom/lib/constants"; import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react"; import type { RouterOutputs } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button"; import { Button } from "@calcom/ui/components/button";
import { DialogContent } from "@calcom/ui/components/dialog"; import { DialogContent } from "@calcom/ui/components/dialog";
import { EmptyScreen } from "@calcom/ui/components/empty-screen"; import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { SkeletonText, SkeletonContainer } from "@calcom/ui/components/skeleton";
const SkeletonLoader = () => {
return (
<SkeletonContainer>
<div className="divide-subtle border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
</SkeletonContainer>
);
};
export const apiKeyModalRef = { export const apiKeyModalRef = {
current: null as null | ((show: boolean) => void), current: null as null | ((show: boolean) => void),
@@ -48,10 +37,12 @@ export const NewApiKeyButton = () => {
); );
}; };
const ApiKeysView = () => { type Props = {
const { t } = useLocale(); apiKeys: RouterOutputs["viewer"]["apiKeys"]["list"];
};
const { data, isPending } = trpc.viewer.apiKeys.list.useQuery(); const ApiKeysView = ({ apiKeys: data }: Props) => {
const { t } = useLocale();
const [apiKeyModal, setApiKeyModal] = useState(false); const [apiKeyModal, setApiKeyModal] = useState(false);
const [apiKeyToEdit, setApiKeyToEdit] = useState<(TApiKeys & { neverExpires?: boolean }) | undefined>( const [apiKeyToEdit, setApiKeyToEdit] = useState<(TApiKeys & { neverExpires?: boolean }) | undefined>(
@@ -67,12 +58,12 @@ const ApiKeysView = () => {
}; };
}, []); }, []);
if (isPending || !data) {
return <SkeletonLoader />;
}
return ( return (
<> <SettingsHeader
title={t("api_keys")}
description={t("create_first_api_key_description", { appName: APP_NAME })}
CTA={<NewApiKeyButton />}
borderInShellHeader={true}>
<LicenseRequired> <LicenseRequired>
<div> <div>
{data?.length ? ( {data?.length ? (
@@ -108,7 +99,7 @@ const ApiKeysView = () => {
<ApiKeyDialogForm handleClose={() => setApiKeyModal(false)} defaultValues={apiKeyToEdit} /> <ApiKeyDialogForm handleClose={() => setApiKeyModal(false)} defaultValues={apiKeyToEdit} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </SettingsHeader>
); );
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@calcom/web", "name": "@calcom/web",
"version": "5.2.3", "version": "5.2.4",
"private": true, "private": true,
"scripts": { "scripts": {
"analyze": "ANALYZE=true next build", "analyze": "ANALYZE=true next build",
@@ -3143,5 +3143,6 @@
"you_and_conjunction": "You &", "you_and_conjunction": "You &",
"email_survey_triggered_by_workflow": "This survey was triggered by a Workflow in Cal.", "email_survey_triggered_by_workflow": "This survey was triggered by a Workflow in Cal.",
"sms_workflow_consent": "By entering your phone number you consent to receive SMS messages for this event. SMS rates may apply.", "sms_workflow_consent": "By entering your phone number you consent to receive SMS messages for this event. SMS rates may apply.",
"sms_opt_out_message": "Text STOP to opt-out of SMS messages",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
} }
+1
View File
@@ -116,6 +116,7 @@
"@vercel/functions": "^1.4.0", "@vercel/functions": "^1.4.0",
"city-timezones": "^1.2.1", "city-timezones": "^1.2.1",
"eslint": "^8.34.0", "eslint": "^8.34.0",
"p-limit": "^6.2.0",
"turbo": "^1.10.1" "turbo": "^1.10.1"
}, },
"resolutions": { "resolutions": {
@@ -23,6 +23,7 @@ import { CTA_CONTAINER_CLASS_NAME } from "./lib/utils";
export type DataTableContextType = { export type DataTableContextType = {
tableIdentifier: string; tableIdentifier: string;
ctaContainerRef: React.RefObject<HTMLDivElement>; ctaContainerRef: React.RefObject<HTMLDivElement>;
filterToOpen: React.MutableRefObject<string | undefined>;
activeFilters: ActiveFilters; activeFilters: ActiveFilters;
clearAll: (exclude?: string[]) => void; clearAll: (exclude?: string[]) => void;
@@ -81,6 +82,7 @@ export function DataTableProvider({
defaultPageSize = DEFAULT_PAGE_SIZE, defaultPageSize = DEFAULT_PAGE_SIZE,
ctaContainerClassName = CTA_CONTAINER_CLASS_NAME, ctaContainerClassName = CTA_CONTAINER_CLASS_NAME,
}: DataTableProviderProps) { }: DataTableProviderProps) {
const filterToOpen = useRef<string | undefined>(undefined);
const [activeFilters, setActiveFilters] = useQueryState( const [activeFilters, setActiveFilters] = useQueryState(
"activeFilters", "activeFilters",
parseAsArrayOf(parseAsJson(ZActiveFilter.parse)).withDefault(DEFAULT_ACTIVE_FILTERS) parseAsArrayOf(parseAsJson(ZActiveFilter.parse)).withDefault(DEFAULT_ACTIVE_FILTERS)
@@ -216,6 +218,7 @@ export function DataTableProvider({
value={{ value={{
tableIdentifier, tableIdentifier,
ctaContainerRef, ctaContainerRef,
filterToOpen,
activeFilters, activeFilters,
addFilter, addFilter,
clearAll, clearAll,
@@ -30,6 +30,7 @@ import {
} from "../../lib/dateRange"; } from "../../lib/dateRange";
import type { FilterableColumn, DateRangeFilterOptions } from "../../lib/types"; import type { FilterableColumn, DateRangeFilterOptions } from "../../lib/types";
import { ZDateRangeFilterValue, ColumnFilterType } from "../../lib/types"; import { ZDateRangeFilterValue, ColumnFilterType } from "../../lib/types";
import { useFilterPopoverOpen } from "./useFilterPopoverOpen";
type DateRangeFilterProps = { type DateRangeFilterProps = {
column: Extract<FilterableColumn, { type: ColumnFilterType.DATE_RANGE }>; column: Extract<FilterableColumn, { type: ColumnFilterType.DATE_RANGE }>;
@@ -38,6 +39,7 @@ type DateRangeFilterProps = {
}; };
export const DateRangeFilter = ({ column, options, showClearButton = false }: DateRangeFilterProps) => { export const DateRangeFilter = ({ column, options, showClearButton = false }: DateRangeFilterProps) => {
const { open, onOpenChange } = useFilterPopoverOpen(column.id);
const filterValue = useFilterValue(column.id, ZDateRangeFilterValue); const filterValue = useFilterValue(column.id, ZDateRangeFilterValue);
const { updateFilter, removeFilter } = useDataTable(); const { updateFilter, removeFilter } = useDataTable();
const range = options?.range ?? "past"; const range = options?.range ?? "past";
@@ -136,7 +138,7 @@ export const DateRangeFilter = ({ column, options, showClearButton = false }: Da
} }
return ( return (
<Popover> <Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
color="secondary" color="secondary"
@@ -15,6 +15,7 @@ import {
isNumberFilterValue, isNumberFilterValue,
} from "../../lib/utils"; } from "../../lib/utils";
import { FilterOptions } from "./FilterOptions"; import { FilterOptions } from "./FilterOptions";
import { useFilterPopoverOpen } from "./useFilterPopoverOpen";
import { numberFilterOperatorOptions, useTextFilterOperatorOptions } from "./utils"; import { numberFilterOperatorOptions, useTextFilterOperatorOptions } from "./utils";
const FILTER_ICONS: Record<ColumnFilterType, IconName> = { const FILTER_ICONS: Record<ColumnFilterType, IconName> = {
@@ -132,9 +133,10 @@ function AppliedFilterValue({
export function FilterPopover({ column }: FilterPopoverProps) { export function FilterPopover({ column }: FilterPopoverProps) {
const icon = column.icon || FILTER_ICONS[column.type]; const icon = column.icon || FILTER_ICONS[column.type];
const filterValue = useFilterValue(column.id, ZFilterValue); const filterValue = useFilterValue(column.id, ZFilterValue);
const { open, onOpenChange } = useFilterPopoverOpen(column.id);
return ( return (
<Popover> <Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
color="secondary" color="secondary"
@@ -0,0 +1,36 @@
import { useState, useEffect, useCallback } from "react";
import { useDataTable } from "../../hooks";
export function useFilterPopoverOpen(columnId: string) {
const { filterToOpen } = useDataTable();
const [open, setOpen] = useState(false);
useEffect(() => {
let timeoutId: NodeJS.Timeout | undefined;
if (filterToOpen.current === columnId) {
timeoutId = setTimeout(() => {
setOpen(true);
}, 0);
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [filterToOpen, columnId]);
const onOpenChange = useCallback(
(open: boolean) => {
if (filterToOpen.current === columnId && !open) {
filterToOpen.current = undefined;
}
setOpen(open);
},
[filterToOpen, columnId]
);
return { open, onOpenChange };
}
@@ -1,7 +1,6 @@
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc"; import { trpc } from "@calcom/trpc";
import { Button } from "@calcom/ui/components/button"; import { Dialog, ConfirmationDialogContent } from "@calcom/ui/components/dialog";
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog";
import { showToast } from "@calcom/ui/components/toast"; import { showToast } from "@calcom/ui/components/toast";
import { useDataTable } from "../../hooks"; import { useDataTable } from "../../hooks";
@@ -45,18 +44,15 @@ export function DeleteSegmentDialog({
onClose(); onClose();
} }
}}> }}>
<DialogContent> <ConfirmationDialogContent
<DialogHeader title={t("delete_segment")} /> variety="danger"
<div className="mb-6">{t("delete_segment_confirmation")}</div> title={t("delete_segment")}
<DialogFooter> confirmBtnText={t("delete")}
<Button color="secondary" onClick={onClose}> cancelBtnText={t("cancel")}
{t("cancel")} isPending={isPending}
</Button> onConfirm={handleDelete}>
<Button color="destructive" onClick={handleDelete} loading={isPending}> <p className="mt-5">{t("delete_segment_confirmation")}</p>
{t("delete")} </ConfirmationDialogContent>
</Button>
</DialogFooter>
</DialogContent>
</Dialog> </Dialog>
); );
} }
@@ -6,14 +6,15 @@ import type { TApiKeys } from "@calcom/ee/api-keys/components/ApiKeyListItem";
import LicenseRequired from "@calcom/ee/common/components/LicenseRequired"; import LicenseRequired from "@calcom/ee/common/components/LicenseRequired";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
import { DialogFooter } from "@calcom/ui/components/dialog";
import { Button } from "@calcom/ui/components/button"; import { Button } from "@calcom/ui/components/button";
import { DialogFooter } from "@calcom/ui/components/dialog";
import { Form } from "@calcom/ui/components/form"; import { Form } from "@calcom/ui/components/form";
import { TextField } from "@calcom/ui/components/form"; import { TextField } from "@calcom/ui/components/form";
import { SelectField } from "@calcom/ui/components/form"; import { SelectField } from "@calcom/ui/components/form";
import { Switch } from "@calcom/ui/components/form"; import { Switch } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast"; import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip"; import { Tooltip } from "@calcom/ui/components/tooltip";
import { revalidateApiKeysList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/api-keys/actions";
export default function ApiKeyDialogForm({ export default function ApiKeyDialogForm({
defaultValues, defaultValues,
@@ -28,6 +29,7 @@ export default function ApiKeyDialogForm({
const updateApiKeyMutation = trpc.viewer.apiKeys.edit.useMutation({ const updateApiKeyMutation = trpc.viewer.apiKeys.edit.useMutation({
onSuccess() { onSuccess() {
utils.viewer.apiKeys.list.invalidate(); utils.viewer.apiKeys.list.invalidate();
revalidateApiKeysList();
showToast(t("api_key_updated"), "success"); showToast(t("api_key_updated"), "success");
handleClose(); handleClose();
}, },
@@ -131,6 +133,7 @@ export default function ApiKeyDialogForm({
setApiKey(apiKey); setApiKey(apiKey);
setApiKeyDetails({ ...event }); setApiKeyDetails({ ...event });
await utils.viewer.apiKeys.list.invalidate(); await utils.viewer.apiKeys.list.invalidate();
revalidateApiKeysList();
setSuccessfulNewApiKeyModal(true); setSuccessfulNewApiKeyModal(true);
} }
}} }}
@@ -2,6 +2,9 @@ import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react"; import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { import {
Dropdown, Dropdown,
DropdownItem, DropdownItem,
@@ -9,10 +12,8 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown"; } from "@calcom/ui/components/dropdown";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import classNames from "@calcom/ui/classNames";
import { showToast } from "@calcom/ui/components/toast"; import { showToast } from "@calcom/ui/components/toast";
import { revalidateApiKeysList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/api-keys/actions";
export type TApiKeys = RouterOutputs["viewer"]["apiKeys"]["list"][number]; export type TApiKeys = RouterOutputs["viewer"]["apiKeys"]["list"][number];
@@ -34,6 +35,7 @@ const ApiKeyListItem = ({
const deleteApiKey = trpc.viewer.apiKeys.delete.useMutation({ const deleteApiKey = trpc.viewer.apiKeys.delete.useMutation({
async onSuccess() { async onSuccess() {
await utils.viewer.apiKeys.list.invalidate(); await utils.viewer.apiKeys.list.invalidate();
revalidateApiKeysList();
showToast(t("api_key_deleted"), "success"); showToast(t("api_key_deleted"), "success");
}, },
onError(err) { onError(err) {
@@ -49,14 +49,13 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => {
); );
if (!organizationOnboarding) { if (!organizationOnboarding) {
logger.error( // Invoice Paid is received for all organizations, even those that were created before Organization Onboarding was introduced.
`NonRecoverableError: No onboarding record found for stripe customer id: ${invoice.customer}.` logger.info(
`No onboarding record found for stripe customer id: ${invoice.customer}, Organization created before Organization Onboarding was introduced, so ignoring the webhook`
); );
// Don't throw as we don't want to retry.
return { return {
success: false, success: true,
error: `No onboarding record found for stripe customer id: ${invoice.customer}.`,
}; };
} }
@@ -0,0 +1,28 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { determineOptOutType } from "../lib/reminders/providers/twilioProvider";
import { WorkflowOptOutContactRepository } from "../lib/repository/workflowOptOutContact";
import { WorkflowOptOutService } from "../lib/service/workflowOptOutService";
const handleSMSResponse = async (request: NextRequest) => {
try {
const optOutResult = await determineOptOutType(request);
if ("error" in optOutResult) {
return NextResponse.json({ message: optOutResult.error }, { status: 400 });
}
if (optOutResult.optOutStatus) {
await WorkflowOptOutService.optOutPhoneNumber(optOutResult.phoneNumber);
} else {
await WorkflowOptOutContactRepository.removePhoneNumber(optOutResult.phoneNumber);
}
return NextResponse.json({ message: "Opt status updated successfully" }, { status: 200 });
} catch (e) {
console.error("Error processing user response webhook:", e);
return NextResponse.json({ message: "Internal server error" }, { status: 500 });
}
};
export default handleSMSResponse;
@@ -18,6 +18,7 @@ import * as twilio from "../lib/reminders/providers/twilioProvider";
import type { VariablesType } from "../lib/reminders/templates/customTemplate"; import type { VariablesType } from "../lib/reminders/templates/customTemplate";
import customTemplate from "../lib/reminders/templates/customTemplate"; import customTemplate from "../lib/reminders/templates/customTemplate";
import smsReminderTemplate from "../lib/reminders/templates/smsReminderTemplate"; import smsReminderTemplate from "../lib/reminders/templates/smsReminderTemplate";
import { WorkflowOptOutService } from "../lib/service/workflowOptOutService";
export async function handler(req: NextRequest) { export async function handler(req: NextRequest) {
const apiKey = req.headers.get("authorization") || req.nextUrl.searchParams.get("apiKey"); const apiKey = req.headers.get("authorization") || req.nextUrl.searchParams.get("apiKey");
@@ -173,6 +174,10 @@ export async function handler(req: NextRequest) {
} }
if (message?.length && message?.length > 0 && sendTo) { if (message?.length && message?.length > 0 && sendTo) {
if (process.env.TWILIO_OPT_OUT_ENABLED === "true") {
message = await WorkflowOptOutService.addOptOutMessage(message, locale || "en");
}
const scheduledSMS = await twilio.scheduleSMS( const scheduledSMS = await twilio.scheduleSMS(
sendTo, sendTo,
message, message,
@@ -1,7 +1,9 @@
import type { NextRequest } from "next/server";
import TwilioClient from "twilio"; import TwilioClient from "twilio";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError"; import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import { setTestSMS } from "@calcom/lib/testSMS"; import { setTestSMS } from "@calcom/lib/testSMS";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
@@ -196,3 +198,92 @@ async function isLockedForSMSSending(userId?: number | null, teamId?: number | n
return user?.smsLockState === SMSLockState.LOCKED; return user?.smsLockState === SMSLockState.LOCKED;
} }
} }
export async function validateWebhookRequest({
requestUrl,
params,
signature,
}: {
requestUrl: string;
params: object;
signature: string;
}) {
if (!process.env.TWILIO_TOKEN) {
throw new Error("TWILIO_TOKEN is not set");
}
const isSignatureValid = TwilioClient.validateRequest(
process.env.TWILIO_TOKEN,
signature,
requestUrl,
params
);
return isSignatureValid;
}
export async function determineOptOutType(
req: NextRequest
): Promise<{ phoneNumber: string; optOutStatus: boolean } | { error: string }> {
const signature = req.headers.get("X-Twilio-Signature");
const formData = await req.formData();
const params = Object.fromEntries(formData.entries());
if (!signature) {
return { error: "Missing Twilio signature" };
}
const fullUrl = `${WEBAPP_URL}${req.nextUrl.pathname}`;
const isSignatureValid = await validateWebhookRequest({
requestUrl: fullUrl,
params: params,
signature: signature,
});
if (!isSignatureValid) {
return { error: "Invalid signature" };
}
const accountSid = params["AccountSid"]?.valueOf();
if (accountSid !== process.env.TWILIO_SID) {
return { error: "Invalid account SID" };
}
// Twilio returns phone numbers with a + prefix
const phoneNumberRaw = params["From"]?.valueOf();
if (!phoneNumberRaw) {
return { error: "No phone number to handle" };
}
const phoneNumber = phoneNumberRaw.toString();
if (!formData.get("OptOutType")) {
return { error: "No opt out message to handle" };
}
const optOutMessage = formData.get("OptOutType")?.valueOf();
if (optOutMessage !== "STOP" && optOutMessage !== "START") {
return { error: "Invalid opt out type" };
}
const optOutStatus = optOutMessage === "STOP" ? true : false;
return { phoneNumber, optOutStatus };
}
export async function deleteMultipleScheduledSMS(referenceIds: string[]) {
const twilio = createTwilioClient();
const pLimit = (await import("p-limit")).default;
const limit = pLimit(10);
await Promise.allSettled(
referenceIds.map((referenceId) => {
return limit(() => twilio.messages(referenceId).update({ status: "canceled" })).catch((error) => {
log.error(`Error canceling scheduled SMS with id ${referenceId}`, error);
});
})
);
}
@@ -2,6 +2,7 @@ import dayjs from "@calcom/dayjs";
import { bulkShortenLinks } from "@calcom/ee/workflows/lib/reminders/utils"; import { bulkShortenLinks } from "@calcom/ee/workflows/lib/reminders/utils";
import { SENDER_ID, WEBSITE_URL } from "@calcom/lib/constants"; import { SENDER_ID, WEBSITE_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { TimeFormat } from "@calcom/lib/timeFormat"; import type { TimeFormat } from "@calcom/lib/timeFormat";
import type { PrismaClient } from "@calcom/prisma"; import type { PrismaClient } from "@calcom/prisma";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
@@ -12,6 +13,8 @@ import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { CalEventResponses, RecurringEvent } from "@calcom/types/Calendar"; import type { CalEventResponses, RecurringEvent } from "@calcom/types/Calendar";
import { getSenderId } from "../alphanumericSenderIdSupport"; import { getSenderId } from "../alphanumericSenderIdSupport";
import { WorkflowOptOutContactRepository } from "../repository/workflowOptOutContact";
import { WorkflowOptOutService } from "../service/workflowOptOutService";
import type { ScheduleReminderArgs } from "./emailReminderManager"; import type { ScheduleReminderArgs } from "./emailReminderManager";
import * as twilio from "./providers/twilioProvider"; import * as twilio from "./providers/twilioProvider";
import type { VariablesType } from "./templates/customTemplate"; import type { VariablesType } from "./templates/customTemplate";
@@ -100,6 +103,14 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => {
return; return;
} }
if (reminderPhone && (await WorkflowOptOutContactRepository.isOptedOut(reminderPhone))) {
log.warn(
`Phone number opted out of SMS workflows`,
safeStringify({ workflowStep: workflowStepId, eventUid: evt.uid })
);
return;
}
const { startTime, endTime } = evt; const { startTime, endTime } = evt;
const uid = evt.uid as string; const uid = evt.uid as string;
const currentDate = dayjs(); const currentDate = dayjs();
@@ -206,6 +217,10 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => {
log.debug(`Sending sms for trigger ${triggerEvent}`, smsMessage); log.debug(`Sending sms for trigger ${triggerEvent}`, smsMessage);
if (smsMessage.length > 0 && reminderPhone && isNumberVerified) { if (smsMessage.length > 0 && reminderPhone && isNumberVerified) {
if (process.env.TWILIO_OPT_OUT_ENABLED === "true") {
smsMessage = await WorkflowOptOutService.addOptOutMessage(smsMessage, evt.organizer.language.locale);
}
//send SMS when event is booked/cancelled/rescheduled //send SMS when event is booked/cancelled/rescheduled
if ( if (
triggerEvent === WorkflowTriggerEvents.NEW_EVENT || triggerEvent === WorkflowTriggerEvents.NEW_EVENT ||
@@ -1,6 +1,10 @@
import { dub } from "@calcom/features/auth/lib/dub"; import { dub } from "@calcom/features/auth/lib/dub";
export const bulkShortenLinks = async (links: string[]) => { export const bulkShortenLinks = async (links: string[]) => {
if (!process.env.DUB_API_KEY) {
return links.map((link) => ({ shortLink: link }));
}
const linksToShorten = links.filter((link) => link); const linksToShorten = links.filter((link) => link);
const results = await dub.links.createMany( const results = await dub.links.createMany(
linksToShorten.map((link) => ({ linksToShorten.map((link) => ({
@@ -0,0 +1,51 @@
import prisma from "@calcom/prisma";
import { WorkflowContactType } from "@calcom/prisma/enums";
export class WorkflowOptOutContactRepository {
static async addPhoneNumber(phoneNumber: string) {
await prisma.workflowOptOutContact.upsert({
where: {
type_value: {
type: WorkflowContactType.PHONE,
value: phoneNumber,
},
},
update: {
optedOut: true,
},
create: {
type: WorkflowContactType.PHONE,
optedOut: true,
value: phoneNumber,
},
});
}
static async removePhoneNumber(phoneNumber: string) {
await prisma.workflowOptOutContact.update({
where: {
type_value: {
type: WorkflowContactType.PHONE,
value: phoneNumber,
},
},
data: {
optedOut: false,
},
});
}
static async isOptedOut(phoneNumber: string) {
const optOutContact = await prisma.workflowOptOutContact.findFirst({
where: {
type: WorkflowContactType.PHONE,
value: phoneNumber,
optedOut: true,
},
select: {
optedOut: true,
},
});
return !!optOutContact?.optedOut;
}
}
@@ -0,0 +1,34 @@
import prisma from "@calcom/prisma";
import { WorkflowMethods, WorkflowActions } from "@calcom/prisma/enums";
export class WorkflowReminderRepository {
static getFutureScheduledAttendeeSMSReminders(phoneNumber: string) {
return prisma.workflowReminder.findMany({
where: {
method: WorkflowMethods.SMS,
cancelled: null,
scheduledDate: { gte: new Date() },
booking: {
smsReminderNumber: phoneNumber,
},
workflowStep: {
action: WorkflowActions.SMS_ATTENDEE,
},
},
});
}
static deleteWorkflowReminders(ids: number[]) {
if (!ids.length) {
return [];
}
return prisma.workflowReminder.deleteMany({
where: {
id: {
in: ids,
},
},
});
}
}
@@ -0,0 +1,32 @@
import { getTranslation } from "@calcom/lib/server/i18n";
import { deleteMultipleScheduledSMS } from "../reminders/providers/twilioProvider";
import { WorkflowOptOutContactRepository } from "../repository/workflowOptOutContact";
import { WorkflowReminderRepository } from "../repository/workflowReminder";
export class WorkflowOptOutService {
static async optOutPhoneNumber(phoneNumber: string) {
await WorkflowOptOutContactRepository.addPhoneNumber(phoneNumber);
// Delete scheduled workflows
const scheduledReminders = await WorkflowReminderRepository.getFutureScheduledAttendeeSMSReminders(
phoneNumber
);
// Get twilio scheduled workflows reminders
await deleteMultipleScheduledSMS(
scheduledReminders
.filter((reminder) => !!reminder.referenceId)
.map((reminder) => reminder.referenceId as string)
);
// Get not twilio scheduled yet workflows
await WorkflowReminderRepository.deleteWorkflowReminders(
scheduledReminders.filter((reminder) => !reminder.referenceId).map((reminder) => reminder.id)
);
}
static async addOptOutMessage(message: string, locale: string) {
const t = await getTranslation(locale, "common");
return `${message}\n\n${t("sms_opt_out_message")}`;
}
}
+122 -104
View File
@@ -37,12 +37,13 @@ import { HorizontalTabs } from "@calcom/ui/components/navigation";
import { showToast } from "@calcom/ui/components/toast"; import { showToast } from "@calcom/ui/components/toast";
import { useBookerTime } from "../bookings/Booker/components/hooks/useBookerTime"; import { useBookerTime } from "../bookings/Booker/components/hooks/useBookerTime";
import { EmbedTabName } from "./lib/EmbedTabs";
import { buildCssVarsPerTheme } from "./lib/buildCssVarsPerTheme"; import { buildCssVarsPerTheme } from "./lib/buildCssVarsPerTheme";
import { EmbedTheme } from "./lib/constants"; import { EmbedTheme } from "./lib/constants";
import { getDimension } from "./lib/getDimension"; import { getDimension } from "./lib/getDimension";
import { useEmbedDialogCtx } from "./lib/hooks/useEmbedDialogCtx"; import { useEmbedDialogCtx } from "./lib/hooks/useEmbedDialogCtx";
import { useEmbedParams } from "./lib/hooks/useEmbedParams"; import { useEmbedParams } from "./lib/hooks/useEmbedParams";
import type { EmbedTabs, EmbedType, EmbedTypes, PreviewState } from "./types"; import type { EmbedTabs, EmbedType, EmbedTypes, PreviewState, EmbedConfig } from "./types";
type EventType = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"] | undefined; type EventType = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"] | undefined;
type EmbedDialogProps = { type EmbedDialogProps = {
@@ -932,55 +933,58 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
open={isEmbedCustomizationOpen} open={isEmbedCustomizationOpen}
onOpenChange={() => setIsEmbedCustomizationOpen((val) => !val)}> onOpenChange={() => setIsEmbedCustomizationOpen((val) => !val)}>
<CollapsibleContent className="text-sm"> <CollapsibleContent className="text-sm">
<div className={classNames(embedType === "inline" ? "block" : "hidden")}> {/* Conditionally render Window Sizing only if inline embed AND NOT React Atom */}
{/*TODO: Add Auto/Fixed toggle from Figma */} {embedType === "inline" && embedParams.embedTabName !== EmbedTabName.ATOM_REACT && (
<div className="text-default mb-[9px] text-sm">Window sizing</div> <div>
<div className="justify-left mb-6 flex items-center !font-normal "> {/*TODO: Add Auto/Fixed toggle from Figma */}
<div className="mr-[9px]"> <div className="text-default mb-[9px] text-sm">Window sizing</div>
<div className="justify-left mb-6 flex items-center !font-normal ">
<div className="mr-[9px]">
<TextField
labelProps={{ className: "hidden" }}
className="focus:ring-offset-0"
required
value={previewState.inline.width}
onChange={(e) => {
setPreviewState((previewState) => {
const width = e.target.value || "100%";
return {
...previewState,
inline: {
...previewState.inline,
width,
},
};
});
}}
addOnLeading={<>W</>}
/>
</div>
<TextField <TextField
labelProps={{ className: "hidden" }} labelProps={{ className: "hidden" }}
className="focus:ring-offset-0" className="focus:ring-offset-0"
value={previewState.inline.height}
required required
value={previewState.inline.width}
onChange={(e) => { onChange={(e) => {
setPreviewState((previewState) => { const height = e.target.value || "100%";
const width = e.target.value || "100%";
setPreviewState((previewState) => {
return { return {
...previewState, ...previewState,
inline: { inline: {
...previewState.inline, ...previewState.inline,
width, height,
}, },
}; };
}); });
}} }}
addOnLeading={<>W</>} addOnLeading={<>H</>}
/> />
</div> </div>
<TextField
labelProps={{ className: "hidden" }}
className="focus:ring-offset-0"
value={previewState.inline.height}
required
onChange={(e) => {
const height = e.target.value || "100%";
setPreviewState((previewState) => {
return {
...previewState,
inline: {
...previewState.inline,
height,
},
};
});
}}
addOnLeading={<>H</>}
/>
</div> </div>
</div> )}
<div <div
className={classNames( className={classNames(
"items-center justify-between", "items-center justify-between",
@@ -1105,51 +1109,53 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
onOpenChange={() => setIsBookingCustomizationOpen((val) => !val)}> onOpenChange={() => setIsBookingCustomizationOpen((val) => !val)}>
<CollapsibleContent> <CollapsibleContent>
<div className="text-sm"> <div className="text-sm">
<Label className="mb-6"> {/* Conditionally render EmbedTheme only if NOT React Atom */}
<div className="mb-2">Theme</div> {embedParams.embedTabName !== EmbedTabName.ATOM_REACT && (
<Select <Label className="mb-6">
className="w-full" <div className="mb-2">EmbedTheme</div>
defaultValue={ThemeOptions[0]} <Select
components={{ className="w-full"
Control: ThemeSelectControl, defaultValue={ThemeOptions[0]}
IndicatorSeparator: () => null, components={{
}} Control: ThemeSelectControl,
onChange={(option) => { IndicatorSeparator: () => null,
if (!option) { }}
return; onChange={(option) => {
} if (!option) {
setPreviewState((previewState) => { return;
return { }
...previewState, setPreviewState((previewState) => {
inline: { // Ensure theme is updated in config for all embed types
...previewState.inline, const newConfig = (currentConfig?: EmbedConfig) => ({
config: { ...(currentConfig ?? {}),
...(previewState.inline.config ?? {}), theme: option.value,
theme: option.value, });
return {
...previewState,
inline: {
...previewState.inline,
config: newConfig(previewState.inline.config),
}, },
}, floatingPopup: {
floatingPopup: { ...previewState.floatingPopup,
...previewState.floatingPopup, config: newConfig(previewState.floatingPopup.config),
config: {
...(previewState.floatingPopup.config ?? {}),
theme: option.value,
}, },
}, elementClick: {
elementClick: { ...previewState.elementClick,
...previewState.elementClick, config: newConfig(previewState.elementClick.config),
config: {
...(previewState.elementClick.config ?? {}),
theme: option.value,
}, },
}, // Keep updating top-level theme for preview iframe
theme: option.value, theme: option.value,
}; };
}); });
}} }}
options={ThemeOptions} options={ThemeOptions}
/> />
</Label> </Label>
{!eventTypeHideOptionDisabled ? ( )}
{/* Conditionally render Hide Details Switch only if NOT Atom embed AND not disabled by prop */}
{!eventTypeHideOptionDisabled &&
embedParams.embedTabName !== EmbedTabName.ATOM_REACT ? (
<div className="mb-6 flex items-center justify-start space-x-2 rtl:space-x-reverse"> <div className="mb-6 flex items-center justify-start space-x-2 rtl:space-x-reverse">
<Switch <Switch
checked={previewState.hideEventTypeDetails} checked={previewState.hideEventTypeDetails}
@@ -1165,31 +1171,33 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
<div className="text-default text-sm">{t("hide_eventtype_details")}</div> <div className="text-default text-sm">{t("hide_eventtype_details")}</div>
</div> </div>
) : null} ) : null}
{[ {/* Conditionally render Brand Colors only if NOT React Atom */}
{ name: "brandColor", title: "light_brand_color" }, {embedParams.embedTabName !== EmbedTabName.ATOM_REACT &&
{ name: "darkBrandColor", title: "dark_brand_color" }, [
// { name: "lightColor", title: "Light Color" }, { name: "brandColor", title: "light_brand_color" },
// { name: "lighterColor", title: "Lighter Color" }, { name: "darkBrandColor", title: "dark_brand_color" },
// { name: "lightestColor", title: "Lightest Color" }, // { name: "lightColor", title: "Light Color" },
// { name: "highlightColor", title: "Highlight Color" }, // { name: "lighterColor", title: "Lighter Color" },
// { name: "medianColor", title: "Median Color" }, // { name: "lightestColor", title: "Lightest Color" },
].map((palette) => ( // { name: "highlightColor", title: "Highlight Color" },
<Label key={palette.name} className="mb-6"> // { name: "medianColor", title: "Median Color" },
<div className="mb-2">{t(palette.title)}</div> ].map((palette) => (
<div className="w-full"> <Label key={palette.name} className="mb-6">
<ColorPicker <div className="mb-2">{t(palette.title)}</div>
popoverAlign="start" <div className="w-full">
container={dialogContentRef?.current ?? undefined} <ColorPicker
defaultValue={paletteDefaultValue(palette.name)} popoverAlign="start"
onChange={(color) => { container={dialogContentRef?.current ?? undefined}
addToPalette({ defaultValue={paletteDefaultValue(palette.name)}
[palette.name as keyof (typeof previewState)["palette"]]: color, onChange={(color) => {
}); addToPalette({
}} [palette.name as keyof (typeof previewState)["palette"]]: color,
/> });
</div> }}
</Label> />
))} </div>
</Label>
))}
<Label className="mb-6"> <Label className="mb-6">
<div className="mb-2">{t("layout")}</div> <div className="mb-2">{t("layout")}</div>
<Select <Select
@@ -1200,16 +1208,26 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
return; return;
} }
setPreviewState((previewState) => { setPreviewState((previewState) => {
const config = { // Ensure layout is updated in config for all embed types
...(previewState.floatingPopup.config ?? {}), const newConfig = (currentConfig?: EmbedConfig) => ({
...(currentConfig ?? {}),
layout: option.value, layout: option.value,
}; });
return { return {
...previewState, ...previewState,
inline: {
...previewState.inline,
config: newConfig(previewState.inline.config),
},
floatingPopup: { floatingPopup: {
...previewState.floatingPopup, ...previewState.floatingPopup,
config, config: newConfig(previewState.floatingPopup.config),
}, },
elementClick: {
...previewState.elementClick,
config: newConfig(previewState.elementClick.config),
},
// Keep updating top-level layout for preview iframe
layout: option.value, layout: option.value,
}; };
}); });
+161 -45
View File
@@ -32,23 +32,23 @@ export const Codes = {
const namespaceProp = `${namespace ? `namespace="${namespace}"` : ""}`; const namespaceProp = `${namespace ? `namespace="${namespace}"` : ""}`;
const argumentForGetCalApi = getArgumentForGetCalApi(namespace); const argumentForGetCalApi = getArgumentForGetCalApi(namespace);
return code` return code`
import Cal, { getCalApi } from "@calcom/embed-react"; import Cal, { getCalApi } from "@calcom/embed-react";
import { useEffect } from "react"; import { useEffect } from "react";
export default function MyApp() { export default function MyApp() {
useEffect(()=>{ useEffect(() => {
(async function () { (async function () {
const cal = await getCalApi(${argumentForGetCalApi ? JSON.stringify(argumentForGetCalApi) : ""}); const cal = await getCalApi(${argumentForGetCalApi ? JSON.stringify(argumentForGetCalApi) : ""});
${uiInstructionCode} ${uiInstructionCode}
})(); })();
}, []) }, [])
return <Cal ${namespaceProp} return <Cal ${namespaceProp}
calLink="${calLink}" calLink="${calLink}"
style={{width:"${width}",height:"${height}",overflow:"scroll"}} style={{width:"${width}",height:"${height}",overflow:"scroll"}}
config={${JSON.stringify(previewState.config)}} config={${JSON.stringify(previewState.config)}}
${doWeNeedCalOriginProp(embedCalOrigin) ? ` calOrigin="${embedCalOrigin}"` : ""} ${doWeNeedCalOriginProp(embedCalOrigin) ? `calOrigin="${embedCalOrigin}"` : ""}
${IS_SELF_HOSTED ? `embedJsUrl="${embedLibUrl}"` : ""} ${IS_SELF_HOSTED ? `embedJsUrl="${embedLibUrl}"` : ""}
/>; />;
};`; };`;
}, },
"floating-popup": ({ "floating-popup": ({
calLink, calLink,
@@ -70,17 +70,17 @@ export const Codes = {
...previewState, ...previewState,
}); });
return code` return code`
import { getCalApi } from "@calcom/embed-react"; import { getCalApi } from "@calcom/embed-react";
import { useEffect } from "react"; import { useEffect } from "react";
export default function MyApp() { export default function MyApp() {
useEffect(()=>{ useEffect(() => {
(async function () { (async function () {
const cal = await getCalApi(${argumentForGetCalApi ? JSON.stringify(argumentForGetCalApi) : ""}); const cal = await getCalApi(${argumentForGetCalApi ? JSON.stringify(argumentForGetCalApi) : ""});
${getApiNameForReactSnippet({ mainApiName: "cal" })}("floatingButton", ${floatingButtonArg}); ${getApiNameForReactSnippet({ mainApiName: "cal" })}("floatingButton", ${floatingButtonArg});
${uiInstructionCode} ${uiInstructionCode}
})(); })();
}, []) }, [])
};`; };`;
}, },
"element-click": ({ "element-click": ({
calLink, calLink,
@@ -97,21 +97,138 @@ export const Codes = {
}) => { }) => {
const argumentForGetCalApi = getArgumentForGetCalApi(namespace); const argumentForGetCalApi = getArgumentForGetCalApi(namespace);
return code` return code`
import { getCalApi } from "@calcom/embed-react"; import { getCalApi } from "@calcom/embed-react";
import { useEffect } from "react"; import { useEffect } from "react";
export default function MyApp() { export default function MyApp() {
useEffect(()=>{ useEffect(() => {
(async function () { (async function () {
const cal = await getCalApi(${argumentForGetCalApi ? JSON.stringify(argumentForGetCalApi) : ""}); const cal = await getCalApi(${argumentForGetCalApi ? JSON.stringify(argumentForGetCalApi) : ""});
${uiInstructionCode} ${uiInstructionCode}
})(); })();
}, []) }, [])
return <button data-cal-namespace="${namespace}" return <button data-cal-namespace="${namespace}"
data-cal-link="${calLink}" data-cal-link="${calLink}"
${doWeNeedCalOriginProp(embedCalOrigin) ? ` data-cal-origin="${embedCalOrigin}"` : ""} ${doWeNeedCalOriginProp(embedCalOrigin) ? `data-cal-origin="${embedCalOrigin}"` : ""}
${`data-cal-config='${JSON.stringify(previewState.config)}'`} ${`data-cal-config='${JSON.stringify(previewState.config)}'`}
>Click me</button>; >Click me</button>;
};`; };`;
},
},
"react-atom": {
inline: ({
calLink,
uiInstructionCode,
previewState,
embedCalOrigin,
namespace,
}: {
calLink: string;
uiInstructionCode: string;
previewState: PreviewState["inline"];
embedCalOrigin: string;
namespace: string;
}) => {
return code`
import { BookerEmbed } from "@calcom/atoms";
// You might need to define or import BookerProps depending on your setup
// For example: type BookerProps = { eventTypeSlug: string; calUsername: string; /* other props */ };
export default function Booker( props : BookerProps ) {
return (
<>
<BookerEmbed
// Use the parsed username and event slug from calLink
eventSlug={eventSlug}
// layout can be of three types: COLUMN_VIEW, MONTH_VIEW or WEEK_VIEW,
// you can choose whichever you prefer
view="${previewState.config?.layout || "MONTH_VIEW"}"
username={calUsername}
customClassNames={{
bookerContainer: "border-subtle border",
}}
onCreateBookingSuccess={() => {
console.log("booking created successfully");
}}
/>
</>
);
};`;
},
"floating-popup": ({
calLink,
uiInstructionCode,
previewState,
embedCalOrigin,
namespace,
}: {
calLink: string;
embedCalOrigin: string;
uiInstructionCode: string;
namespace: string;
previewState: PreviewState["floatingPopup"];
}) => {
return code`
import { BookerEmbed } from "@calcom/atoms";
// You might need to define or import BookerProps depending on your setup
export default function Booker( props : BookerProps ) {
return (
<>
<BookerEmbed
// Use the parsed username and event slug from calLink
eventSlug={eventSlug}
// layout can be of three types: COLUMN_VIEW, MONTH_VIEW or WEEK_VIEW,
// you can choose whichever you prefer
view="${previewState.config?.layout || "MONTH_VIEW"}"
username={calUsername}
customClassNames={{
bookerContainer: "border-subtle border",
}}
onCreateBookingSuccess={() => {
console.log("booking created successfully");
}}
/>
</>
);
};`;
},
"element-click": ({
calLink,
uiInstructionCode,
previewState,
embedCalOrigin,
namespace,
}: {
calLink: string;
uiInstructionCode: string;
previewState: PreviewState["elementClick"];
embedCalOrigin: string;
namespace: string;
}) => {
return code`
import { BookerEmbed } from "@calcom/atoms";
// You might need to define or import BookerProps depending on your setup
export default function Booker( props : BookerProps ) {
return (
<>
<BookerEmbed
// Use the parsed username and event slug from calLink
eventSlug={eventSlug}
// layout can be of three types: COLUMN_VIEW, MONTH_VIEW or WEEK_VIEW,
// you can choose whichever you prefer
view="${previewState.config?.layout || "MONTH_VIEW"}"
username={calUsername}
customClassNames={{
bookerContainer: "border-subtle border",
}}
onCreateBookingSuccess={() => {
console.log("booking created successfully");
}}
/>
</>
);
};`;
}, },
}, },
HTML: { HTML: {
@@ -176,8 +293,7 @@ export const Codes = {
${uiInstructionCode}`; ${uiInstructionCode}`;
}, },
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any };
} satisfies Record<string, Record<string, (...args: any[]) => string>>;
/** /**
* It allows us to show code with certain reusable blocks indented according to the block variable placement * It allows us to show code with certain reusable blocks indented according to the block variable placement
+61 -6
View File
@@ -15,9 +15,15 @@ import { getApiNameForReactSnippet, getApiNameForVanillaJsSnippet } from "./getA
import { getDimension } from "./getDimension"; import { getDimension } from "./getDimension";
import { useEmbedCalOrigin } from "./hooks"; import { useEmbedCalOrigin } from "./hooks";
export const enum EmbedTabName {
HTML = "embed-code",
IFRAME_REACT = "embed-react",
ATOM_REACT = "embed-atom-react",
}
export const tabs = [ export const tabs = [
{ {
name: "HTML", name: "HTML (iframe)",
href: "embedTabName=embed-code", href: "embedTabName=embed-code",
icon: "code" as const, icon: "code" as const,
type: "code", type: "code",
@@ -74,7 +80,7 @@ export const tabs = [
}), }),
}, },
{ {
name: "React", name: "React (iframe)",
href: "embedTabName=embed-react", href: "embedTabName=embed-react",
"data-testid": "react", "data-testid": "react",
icon: "code" as const, icon: "code" as const,
@@ -104,11 +110,11 @@ export const tabs = [
style={{ resize: "none", overflow: "auto" }} style={{ resize: "none", overflow: "auto" }}
value={`/* First make sure that you have installed the package */ value={`/* First make sure that you have installed the package */
/* If you are using yarn */ /* If you are using yarn */
// yarn add @calcom/embed-react // yarn add @calcom/embed-react
/* If you are using npm */ /* If you are using npm */
// npm install @calcom/embed-react // npm install @calcom/embed-react
${getEmbedTypeSpecificString({ ${getEmbedTypeSpecificString({
embedFramework: "react", embedFramework: "react",
embedType, embedType,
@@ -123,6 +129,55 @@ export const tabs = [
); );
}), }),
}, },
{
name: "React (Atom)",
href: `embedTabName=${EmbedTabName.ATOM_REACT}`,
"data-testid": "react-atom",
icon: "code" as const,
type: "code",
Component: forwardRef<
HTMLTextAreaElement | HTMLIFrameElement | null,
{ embedType: EmbedType; calLink: string; previewState: PreviewState; namespace: string }
>(function EmbedReactAtom({ embedType, calLink, previewState, namespace }, ref) {
const { t } = useLocale();
const embedCalOrigin = useEmbedCalOrigin();
if (ref instanceof Function || !ref) {
return null;
}
if (ref.current && !(ref.current instanceof HTMLTextAreaElement)) {
return null;
}
return (
<>
<small className="text-subtle flex py-2">{t("create_update_react_component")}</small>
<TextArea
data-testid={`${EmbedTabName.ATOM_REACT}`}
ref={ref as typeof ref & MutableRefObject<HTMLTextAreaElement>}
name={`${EmbedTabName.ATOM_REACT}`}
className="text-default bg-default h-[calc(100%-50px)] font-mono"
readOnly
style={{ resize: "none", overflow: "auto" }}
value={`/* First make sure that you have installed the package */
/* If you are using yarn */
// yarn add @calcom/atoms
/* If you are using npm */
// npm install @calcom/atoms
${getEmbedTypeSpecificString({
embedFramework: "react-atom" as EmbedFramework,
embedType,
calLink,
previewState,
embedCalOrigin,
namespace,
})}`}
/>
</>
);
}),
},
{ {
name: "Preview", name: "Preview",
href: "embedTabName=embed-preview", href: "embedTabName=embed-preview",
+1 -1
View File
@@ -53,6 +53,6 @@ export type PreviewState = {
layout: BookerLayouts; layout: BookerLayouts;
}; };
export type EmbedFramework = "react" | "HTML"; export type EmbedFramework = "react" | "react-atom" | "HTML";
export type EmbedTabs = typeof tabs; export type EmbedTabs = typeof tabs;
export type EmbedTypes = ReturnType<typeof useEmbedTypes>; export type EmbedTypes = ReturnType<typeof useEmbedTypes>;
-2
View File
@@ -1,2 +0,0 @@
# Generated dynamically based on Prisma schema
types.ts
-1
View File
@@ -1 +0,0 @@
# kysely
-20
View File
@@ -1,20 +0,0 @@
import { Kysely, PostgresDialect } from "kysely";
import { Pool } from "pg";
import type { DB } from "./types.ts";
const dialect = new PostgresDialect({
pool: new Pool({
database: "calendso",
host: "localhost",
user: "postgres",
password: "postgres",
port: 5450,
}),
});
const db = new Kysely<DB>({
dialect,
});
export default db;
-12
View File
@@ -1,12 +0,0 @@
{
"name": "@calcom/kysely",
"private": true,
"main": "index.ts",
"dependencies": {
"kysely": "^0.26.3",
"pg": "^8.11.3"
},
"devDependencies": {
"@types/pg": "^8.10.7"
}
}
@@ -1,14 +0,0 @@
import type { ExpressionBuilder, StringReference } from "kysely";
import { sql } from "kysely";
export function traverseJSON<DB, TB extends keyof DB>(
eb: ExpressionBuilder<DB, TB>,
column: StringReference<DB, TB>,
path: string | [string, ...string[]]
) {
if (!Array.isArray(path)) {
path = [path];
}
return sql`${sql.ref(column)}->${sql.raw(path.map((item) => `'${item}'`).join("->"))}`;
}
Binary file not shown.
@@ -0,0 +1,17 @@
-- CreateEnum
CREATE TYPE "WorkflowContactType" AS ENUM ('PHONE', 'EMAIL');
-- CreateTable
CREATE TABLE "WorkflowOptOutContact" (
"id" SERIAL NOT NULL,
"type" "WorkflowContactType" NOT NULL,
"value" TEXT NOT NULL,
"optedOut" BOOLEAN NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WorkflowOptOutContact_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "WorkflowOptOutContact_type_value_key" ON "WorkflowOptOutContact"("type", "value");
-1
View File
@@ -30,7 +30,6 @@
"@prisma/extension-accelerate": "^0.6.2", "@prisma/extension-accelerate": "^0.6.2",
"@prisma/generator-helper": "^5.4.2", "@prisma/generator-helper": "^5.4.2",
"prisma": "^5.4.2", "prisma": "^5.4.2",
"prisma-kysely": "^1.7.1",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"zod": "^3.22.4", "zod": "^3.22.4",
"zod-prisma": "^0.5.4" "zod-prisma": "^0.5.4"
+16 -6
View File
@@ -19,12 +19,6 @@ generator zod {
relationModel = "default" relationModel = "default"
} }
generator kysely {
provider = "prisma-kysely"
output = "../kysely"
fileName = "types.ts"
}
generator enums { generator enums {
provider = "ts-node --transpile-only ./enum-generator" provider = "ts-node --transpile-only ./enum-generator"
} }
@@ -2070,3 +2064,19 @@ model BookingInternalNote {
@@unique([bookingId, notePresetId]) @@unique([bookingId, notePresetId])
@@index([bookingId]) @@index([bookingId])
} }
enum WorkflowContactType {
PHONE
EMAIL
}
model WorkflowOptOutContact {
id Int @id @default(autoincrement())
type WorkflowContactType
value String
optedOut Boolean
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([type, value])
}
@@ -112,6 +112,7 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
slug: true, slug: true,
isOrganization: true, isOrganization: true,
isPlatform: true, isPlatform: true,
name: true,
}, },
}, },
}, },
@@ -173,7 +174,10 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
}); });
if (!!hasExistingPlatformOrOrgTeam?.team && isPlatform) { if (!!hasExistingPlatformOrOrgTeam?.team && isPlatform) {
throw new TRPCError({ code: "BAD_REQUEST", message: "User is already part of a team" }); throw new TRPCError({
code: "BAD_REQUEST",
message: `You can't create a new team because you are already a part of ${hasExistingPlatformOrOrgTeam.team.name}`,
});
} }
const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE); const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE);
+1
View File
@@ -408,6 +408,7 @@
"TEAM_ID_VERCEL", "TEAM_ID_VERCEL",
"TELEMETRY_DEBUG", "TELEMETRY_DEBUG",
"TWILIO_MESSAGING_SID", "TWILIO_MESSAGING_SID",
"TWILIO_OPT_OUT_ENABLED",
"TWILIO_PHONE_NUMBER", "TWILIO_PHONE_NUMBER",
"TWILIO_WHATSAPP_PHONE_NUMBER", "TWILIO_WHATSAPP_PHONE_NUMBER",
"TWILIO_SID", "TWILIO_SID",
+96 -813
View File
File diff suppressed because it is too large. Load diff