Compare commits

...
16 Commits
Author SHA1 Message Date
emrysal dfdda8a5b8 chore: release v5.7.2
Post release / release (push) Canceled after 0s
2025-09-19 11:45:35 +00:00
Amit Sharma 286ee6b1bd fix: disable intercom while being impersonated (#23935) 2025-09-19 10:32:24 +00:00
Benny Joo ef8b0fa41c chore: eslint rule for blocking importing features from appstore, lib, prisma (#23832)
* eslint rule

* improve

* fix

* improve msg
2025-09-19 08:05:53 +00:00
7fbeeae25b fix: event type not found 400 and other (#23904)
* fix: various fixes specifically to event-types

* Revamp error handling a little; highly flawed

* fix: Test cases that depended on defaultResponder behaviour

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-09-18 18:56:00 +00:00
Anik Dhabal Babu 5cf0553199 fix: fix few e2e flakes (#23926) 2025-09-18 14:57:15 -03:00
Lauris Skraucis 4f114ef8d3 fix: EventTypeSettings Checkbox booking field label (#23891) 2025-09-18 17:14:06 +00:00
Anik Dhabal BabuandDevin AI a9d6fbd14d fix: resolve blank form in analytics app installation step 3 (#23924)
- Add mounted state to ensure component renders after hydration
- Fix hydration mismatch between server and client rendering
- Ensure ConfigureStepCard renders properly on initial load in production

Fixes the issue where GTM analytics app configuration form appears blank
on step 3 until page refresh.

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-18 17:11:02 +00:00
Eunjae Lee 3e1629add9 chore: compare values in DateTargetSelector (#23916) 2025-09-18 17:10:14 +00:00
f1ac0642d1 fix: correct route pattern and assertions in duplicate API calls E2E test (#23287)
- Fix route pattern from **/api/trpc/viewer.slots.getSchedule** to **/api/trpc/slots/getSchedule**
- Remove permissive assertions that allowed tests to pass with 0 API calls
- Tests now properly fail when duplicate getSchedule calls are detected
- Addresses issue where E2E tests weren't catching duplicate API calls on main branch

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-09-18 17:09:45 +00:00
4689775a1f chore: Skip static file copy if not required (#23903)
* chore: Skip static file copy if not required

* feat: Replace MD5 hash checks with Turborepo caching for static file copying

- Add copy-app-store-static Turborepo task with proper inputs/outputs
- Remove manual MD5 hash calculation logic that was causing performance issues
- Update package.json scripts to use Turborepo task
- Addresses Keith's feedback about using Turborepo cache instead of manual checks

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: Add copy-app-store-static script to package.json for Turborepo task execution

- Add missing script definition in apps/web/package.json
- Enables Turborepo to properly execute the static file copying task
- Fixes 'No tasks were executed' issue by providing the command to run

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: Remove skipped jobs from required check failure condition

- Allow conditional E2E jobs to be skipped without failing the required check
- Maintain failure detection for actual job failures and cancellations
- Apply fix consistently to both pr.yml and all-checks.yml workflows

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* Revert "fix: Remove skipped jobs from required check failure condition"

This reverts commit 429f0506d009eaae7fa88be6db077681d5124f2a.

* Only write new logs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-09-18 16:44:06 +00:00
Benny Joo 4011733271 refactor: remove @calcom/features dependency from @calcom/prisma (#23820) 2025-09-18 16:40:39 +00:00
Keith Williams f41aa234d7 test: Skip broken atom E2E test (#23925) 2025-09-18 13:11:36 -03:00
a44a3e5b84 feat: add Webhook resource to PBAC system with permission enforcement (#23614)
* feat: add Webhook resource to PBAC system with permission enforcement

- Add Webhook resource to PBAC permission registry with CRUD actions
- Implement PBAC permission checks in webhook handlers (create, edit, delete)
- Add webhook permission translations to common.json
- Use PermissionCheckService with fallback roles [ADMIN, OWNER] for team webhooks
- Maintain backward compatibility when PBAC is disabled
- Follow same pattern as workflow PBAC implementation from PR #22845

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* fix: implement PBAC permission filtering in webhook list handler

- Add PermissionCheckService to filter team webhooks by webhook.read permission
- Only show webhooks from teams where user has proper permissions
- Maintain backward compatibility with fallback to all team memberships

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* add migration for default roles

* new forUserMethod

* update webhook repository

* fix UI showing/hiding webhooks for webhoo.create teams

* WIP pbac procedure migratoin + tests

* add more roles to get fallback

* permissions in cmponents instead of readOnly

* passPermissions to list item

* push instant events logic

* Git merge

* wip teamId accessable refactor

* fix delete handler

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-18 07:37:45 -03:00
Hariom Balhara 98a075d062 handle NaN != NaN prefetch (#23910) 2025-09-18 11:34:03 +01:00
Eunjae LeeandDevin AI bdc3cb9d6e feat: allow to choose dateTarget for /insights (startTime by default) (#23752)
* feat: allow to choose dateTarget for /insights (startTime by default)

* feat: add timestamp selector for insights date filtering

- Add TimestampFilter component with Start Time/Created At options
- Extend useInsightsBookingParameters hook with timestamp selection
- Update all insight components to use dateTarget parameter
- Add i18n translations for new UI strings
- Position selector next to DateRangeFilter as requested

Addresses user request to add select box next to date range filter
allowing users to choose between startTime (default) and createdAt
for displaying booking metrics on the Insights page.

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: rename TimestampFilter to DateTargetSelector with nuqs URL state

- Rename TimestampFilter component to DateTargetSelector
- Implement nuqs hook in InsightsPageContent for URL state management
- Update useInsightsBookingParameters to return dateTarget from URL state
- Add dateTarget field to insightsRoutingServiceInputSchema and related types
- Simplify individual insight components to use insightsBookingParams directly
- Remove manual timestampTarget destructuring from all components
- Update all tRPC routing service calls to include dateTarget parameter
- All TypeScript checks now pass successfully

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* update styles

* fix inconsistency

* feat: replace Select with Command component and rename filter ID

- Replace Select with Command + Popover for compact width and wider dropdown
- Add descriptive option labels with translations
- Change filter ID from 'createdAt' to 'timestamp' across all components
- Maintain URL state management with nuqs
- Fix ESLint warning for missing dependency in useEffect

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: revert routing components to use createdAt filter ID

- Keep timestamp filter ID change scoped only to main insights page
- Routing components should continue using createdAt as filter ID
- Only insights-view.tsx and related booking hooks use timestamp

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* update styles

* fix trpc router

* refactor timestamp column for insights booking service

* fix

* update text

* rename and clean up

* fix endDate in DateRangeFilter

* fix type errors

* fix startTime filter and type errors

* provide default date range

* add completed to getMembersStatsWithCount

* add unit tests

* fix type error

* address feedback

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-18 11:32:50 +01:00
Rajiv SahalandAlex van Andel 616e4c4908 chore: add stale time for billing check (#23907)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-09-18 09:47:08 +00:00
94 changed files with 2549 additions and 1035 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": patch
---
fix: EventTypeSettings Checkbox booking field label
+1 -1
View File
@@ -7,7 +7,7 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@calcom/platform-libraries"],
"ignore": ["@calcom/platform-libraries", "@calcom/api-v2"],
"privatePackages": {
"version": false,
"tag": false
+83 -8
View File
@@ -1,4 +1,3 @@
// This configuration only applies to the package manager root.
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: ["./packages/config/eslint-preset.js"],
@@ -7,29 +6,105 @@ module.exports = {
"import/no-cycle": ["warn", { maxDepth: Infinity }],
},
overrides: [
// WARN: features must not be imported by app-store or lib
{
files: ["packages/lib/**/*.{ts,tsx,js,jsx}", "packages/prisma/**/*.{ts,tsx,js,jsx}"],
files: ["packages/app-store/**/*.{ts,tsx,js,jsx}", "packages/lib/**/*.{ts,tsx,js,jsx}"],
rules: {
"no-restricted-imports": [
"warn",
{
paths: ["@calcom/app-store"],
patterns: ["@calcom/app-store/*"],
patterns: [
{
group: [
// Catch all relative paths into features
"**/features",
"**/features/*",
// Catch all alias imports
"@calcom/features",
"@calcom/features/*",
],
message: "Avoid importing @calcom/features from @calcom/app-store or @calcom/lib.",
},
],
},
],
},
},
// WARN: lib must not import app-store or features
{
files: ["packages/lib/**/*.{ts,tsx,js,jsx}"],
rules: {
"no-restricted-imports": [
"warn",
{
patterns: [
{
group: [
// Catch all relative paths into app-store
"**/app-store",
"**/app-store/*",
// Catch all relative paths into features
"**/features",
"**/features/*",
// Catch alias imports
"@calcom/app-store",
"@calcom/app-store/*",
"@calcom/features",
"@calcom/features/*",
],
message: "@calcom/lib should not import @calcom/app-store or @calcom/features.",
},
],
},
],
},
},
// ERROR: app-store must not import trpc
{
files: ["packages/app-store/**/*.{ts,tsx,js,jsx}"],
rules: {
"@typescript-eslint/no-restricted-imports": [
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["@calcom/trpc/*", "@trpc/*"],
message: "tRPC imports are blocked in packages/app-store. Move UI to apps/web/components/apps or introduce an API boundary.",
allowTypeImports: false,
group: [
// Catch all relative paths into trpc
"**/trpc",
"**/trpc/*",
// Catch alias imports
"@calcom/trpc",
"@calcom/trpc/*",
"@trpc",
"@trpc/*",
],
message:
"@calcom/app-store must not import trpc. Move UI to apps/web/components/apps or introduce an API boundary.",
},
],
},
],
},
},
// ERROR: prisma must not import `features` package
{
files: ["packages/prisma/**/*.{ts,tsx,js,jsx}"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
group: [
// Catch all relative paths into features
"**/features",
"**/features/*",
// Catch all alias imports
"@calcom/features",
"@calcom/features/*",
],
message: "Avoid importing @calcom/features from @calcom/prisma.",
},
],
},
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+2 -8
View File
@@ -1,20 +1,14 @@
import { captureException as SentryCaptureException } from "@sentry/nextjs";
import type { NextMiddleware } from "next-api-middleware";
import { redactError } from "@calcom/lib/redactError";
export const captureErrors: NextMiddleware = async (_req, res, next) => {
try {
// Catch any errors that are thrown in remaining
// middleware and the API route handler
await next();
} catch (error) {
console.error(error);
SentryCaptureException(error);
const redactedError = redactError(error);
if (redactedError instanceof Error) {
res.status(400).json({ message: redactedError.message, error: redactedError });
return;
}
res.status(400).json({ message: "Something went wrong", error });
res.status(500).json({ message: "Something went wrong" });
}
};
+68
View File
@@ -0,0 +1,68 @@
import type { Prisma } from "@calcom/prisma/client";
export const eventTypeSelect = {
id: true,
title: true,
slug: true,
length: true,
hidden: true,
position: true,
userId: true,
teamId: true,
scheduleId: true,
eventName: true,
timeZone: true,
periodType: true,
periodStartDate: true,
periodEndDate: true,
periodDays: true,
periodCountCalendarDays: true,
requiresConfirmation: true,
recurringEvent: true,
disableGuests: true,
hideCalendarNotes: true,
minimumBookingNotice: true,
beforeEventBuffer: true,
afterEventBuffer: true,
schedulingType: true,
price: true,
currency: true,
slotInterval: true,
parentId: true,
successRedirectUrl: true,
description: true,
locations: true,
metadata: true,
seatsPerTimeSlot: true,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
bookingFields: true,
bookingLimits: true,
onlyShowFirstAvailableSlot: true,
durationLimits: true,
customInputs: {
select: { id: true, label: true, required: true, options: true, type: true, placeholder: true },
},
hashedLink: {
select: { link: true },
},
team: {
select: { slug: true },
},
hosts: {
select: {
userId: true,
isFixed: true,
scheduleId: true,
},
},
owner: {
select: { username: true, id: true },
},
children: {
select: {
id: true,
userId: true,
},
},
} satisfies Prisma.EventTypeSelect;
+1 -66
View File
@@ -6,13 +6,12 @@ import {
MIN_EVENT_DURATION_MINUTES,
} from "@calcom/lib/constants";
import slugify from "@calcom/lib/slugify";
import { customInputSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import { EventTypeSchema } from "@calcom/prisma/zod/modelSchema/EventTypeSchema";
import { HostSchema } from "@calcom/prisma/zod/modelSchema/HostSchema";
import { Frequency } from "~/lib/types";
import { jsonSchema } from "./shared/jsonSchema";
import { schemaQueryUserId } from "./shared/queryUserId";
import { timeZone } from "./shared/timeZone";
@@ -118,67 +117,3 @@ const schemaEventTypeEditParams = z
.strict();
export const schemaEventTypeEditBodyParams = schemaEventTypeBaseBodyParams.merge(schemaEventTypeEditParams);
export const schemaEventTypeReadPublic = EventTypeSchema.pick({
id: true,
title: true,
slug: true,
length: true,
hidden: true,
position: true,
userId: true,
teamId: true,
scheduleId: true,
eventName: true,
timeZone: true,
periodType: true,
periodStartDate: true,
periodEndDate: true,
periodDays: true,
periodCountCalendarDays: true,
requiresConfirmation: true,
recurringEvent: true,
disableGuests: true,
hideCalendarNotes: true,
minimumBookingNotice: true,
beforeEventBuffer: true,
afterEventBuffer: true,
schedulingType: true,
price: true,
currency: true,
slotInterval: true,
parentId: true,
successRedirectUrl: true,
description: true,
locations: true,
metadata: true,
seatsPerTimeSlot: true,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
bookingFields: true,
bookingLimits: true,
onlyShowFirstAvailableSlot: true,
durationLimits: true,
}).merge(
z.object({
children: z.array(childrenSchema).optional().default([]),
hosts: z.array(hostSchema).optional().default([]),
locations: z
.array(
z.object({
link: z.string().optional(),
address: z.string().optional(),
hostPhoneNumber: z.string().optional(),
type: z.any().optional(),
})
)
.nullable(),
metadata: jsonSchema.nullable(),
customInputs: customInputSchema.array().optional(),
link: z.string().optional(),
hashedLink: z
.array(z.object({ link: z.string() }))
.optional()
.default([]),
bookingFields: eventTypeBookingFields.optional().nullable(),
})
);
@@ -1,8 +1,7 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
@@ -66,4 +65,4 @@ async function checkPermissions(req: NextApiRequest) {
throw new HttpError({ statusCode: 403, message: "Forbidden" });
}
export default defaultResponder(deleteHandler);
export default deleteHandler;
+15 -15
View File
@@ -1,11 +1,10 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
import { eventTypeSelect } from "~/lib/selects/event-type";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
import { checkPermissions as canAccessTeamEventOrThrow } from "~/pages/api/teams/[teamId]/_auth-middleware";
@@ -49,15 +48,13 @@ export async function getHandler(req: NextApiRequest) {
const eventType = await prisma.eventType.findUnique({
where: { id },
include: {
customInputs: true,
hashedLink: { select: { link: true } },
team: { select: { slug: true } },
hosts: { select: { userId: true, isFixed: true } },
owner: { select: { username: true, id: true } },
children: { select: { id: true, userId: true } },
},
select: eventTypeSelect,
});
if (!eventType) {
throw new HttpError({ statusCode: 404, message: "Event type not found" });
}
await checkPermissions(req, eventType);
const link = eventType ? getCalLink(eventType) : null;
@@ -74,9 +71,12 @@ export async function getHandler(req: NextApiRequest) {
eventType.scheduleId = user.defaultScheduleId;
}
// TODO: eventType when not found should be a 404
// but API consumers may depend on the {} behaviour.
return { event_type: schemaEventTypeReadPublic.parse({ ...eventType, link }) };
return {
event_type: {
...eventType,
link,
},
};
}
type BaseEventTypeCheckPermissions = {
@@ -100,4 +100,4 @@ async function checkPermissions<T extends BaseEventTypeCheckPermissions>(
throw new HttpError({ statusCode: 403, message: "Forbidden" });
}
export default defaultResponder(getHandler);
export default getHandler;
@@ -2,13 +2,13 @@ import type { NextApiRequest } from "next";
import type { z } from "zod";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import { eventTypeSelect } from "~/lib/selects/event-type";
import type { schemaEventTypeBaseBodyParams } from "~/lib/validations/event-type";
import { schemaEventTypeEditBodyParams, schemaEventTypeReadPublic } from "~/lib/validations/event-type";
import { schemaEventTypeEditBodyParams } from "~/lib/validations/event-type";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
import ensureOnlyMembersAsHosts from "~/pages/api/event-types/_utils/ensureOnlyMembersAsHosts";
@@ -235,8 +235,8 @@ export async function patchHandler(req: NextApiRequest) {
};
}
await checkPermissions(req, parsedBody);
const eventType = await prisma.eventType.update({ where: { id }, data });
return { event_type: schemaEventTypeReadPublic.parse(eventType) };
const eventType = await prisma.eventType.update({ where: { id }, data, select: eventTypeSelect });
return { event_type: eventType };
}
async function checkPermissions(req: NextApiRequest, body: z.infer<typeof schemaEventTypeBaseBodyParams>) {
@@ -249,4 +249,4 @@ async function checkPermissions(req: NextApiRequest, body: z.infer<typeof schema
await checkTeamEventEditPermission(req, body);
}
export default defaultResponder(patchHandler);
export default patchHandler;
+4 -12
View File
@@ -2,10 +2,10 @@ import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma";
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
import { eventTypeSelect } from "~/lib/selects/event-type";
import { schemaQuerySlug } from "~/lib/validations/shared/querySlug";
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
@@ -54,15 +54,7 @@ async function getHandler(req: NextApiRequest) {
userId: shouldUseUserId ? { in: userIds } : undefined,
slug: slug, // slug will be undefined if not provided in query
},
include: {
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
customInputs: true,
hashedLink: { select: { link: true } },
team: { select: { slug: true } },
hosts: { select: { userId: true, isFixed: true } },
owner: { select: { username: true, id: true } },
children: { select: { id: true, userId: true } },
},
select: eventTypeSelect,
});
// this really should return [], but backwards compatibility..
if (data.length === 0) new HttpError({ statusCode: 404, message: "No event types were found" });
@@ -70,7 +62,7 @@ async function getHandler(req: NextApiRequest) {
event_types: (await defaultScheduleId<(typeof data)[number]>({ eventTypes: data, prisma, userIds })).map(
(eventType) => {
const link = getCalLink(eventType);
return schemaEventTypeReadPublic.parse({ ...eventType, link });
return { ...eventType, link };
}
),
};
+5 -4
View File
@@ -2,11 +2,12 @@ import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { schemaEventTypeCreateBodyParams, schemaEventTypeReadPublic } from "~/lib/validations/event-type";
import { eventTypeSelect } from "~/lib/selects/event-type";
import { schemaEventTypeCreateBodyParams } from "~/lib/validations/event-type";
import { canUserAccessTeamWithRole } from "~/pages/api/teams/[teamId]/_auth-middleware";
import checkParentEventOwnership from "./_utils/checkParentEventOwnership";
@@ -306,10 +307,10 @@ async function postHandler(req: NextApiRequest) {
data.hosts = { createMany: { data: hosts } };
}
const eventType = await prisma.eventType.create({ data, include: { hosts: true } });
const eventType = await prisma.eventType.create({ data, select: eventTypeSelect });
return {
event_type: schemaEventTypeReadPublic.parse(eventType),
event_type: eventType,
message: "Event type created successfully",
};
}
@@ -2,10 +2,10 @@ import type { NextApiRequest } from "next";
import { z } from "zod";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { schemaEventTypeReadPublic } from "~/lib/validations/event-type";
import { eventTypeSelect } from "~/lib/selects/event-type";
const querySchema = z.object({
teamId: z.coerce.number(),
@@ -57,17 +57,11 @@ async function getHandler(req: NextApiRequest) {
members: { some: { userId } },
},
},
include: {
customInputs: true,
team: { select: { slug: true } },
hosts: { select: { userId: true, isFixed: true } },
owner: { select: { username: true, id: true } },
children: { select: { id: true, userId: true } },
},
select: eventTypeSelect,
};
return {
event_types: await prisma.eventType.findMany(args),
};
const data = await prisma.eventType.findMany(args);
return { event_types: data.map((eventType) => schemaEventTypeReadPublic.parse(eventType)) };
}
export default defaultResponder(getHandler);
@@ -60,7 +60,7 @@ describe("DELETE /api/event-types/[id]", () => {
describe("Error", async () => {
test("Fails to remove event type if user is not OWNER/ADMIN of team associated with event type", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "DELETE",
body: {},
query: {
@@ -71,15 +71,18 @@ describe("DELETE /api/event-types/[id]", () => {
// Assign userId to the request objects
req.userId = memberUser;
await handler(req, res);
expect(res.statusCode).toBe(403); // Check if the deletion was successful
await expect(handler(req)).rejects.toThrowError(
expect.objectContaining({
statusCode: 403,
})
);
});
});
describe("Success", async () => {
test("Removes event type if user is owner of team associated with event type", async () => {
// Mocks for DELETE request
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "DELETE",
body: {},
query: {
@@ -90,8 +93,8 @@ describe("DELETE /api/event-types/[id]", () => {
// Assign userId to the request objects
req.userId = adminUser;
await handler(req, res);
expect(res.statusCode).toBe(200); // Check if the deletion was successful
const deletedEventType = await handler(req);
expect(deletedEventType).not.toBeNull();
});
});
});
@@ -16,7 +16,7 @@ type CustomNextApiResponse = NextApiResponse & Response;
describe("GET /api/event-types/[id]", () => {
describe("Errors", () => {
test("Returns 403 if user not admin/team member/event owner", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
@@ -32,16 +32,40 @@ describe("GET /api/event-types/[id]", () => {
);
req.userId = 333333;
await handler(req, res);
expect(res.statusCode).toBe(403);
await expect(handler(req)).rejects.toThrowError(
expect.objectContaining({
statusCode: 403,
})
);
});
test("Returns 404 if event type not found", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: 123456,
},
});
req.isSystemWideAdmin = true;
prismaMock.eventType.findUnique.mockResolvedValue(null);
req.userId = 333333;
await expect(handler(req)).rejects.toThrowError(
expect.objectContaining({
statusCode: 404,
})
);
});
});
describe("Success", async () => {
test("Returns event type if user is admin", async () => {
const eventTypeId = 123456;
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
@@ -57,17 +81,17 @@ describe("GET /api/event-types/[id]", () => {
req.isSystemWideAdmin = true;
req.userId = 333333;
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res._getData()).event_type.id).toEqual(eventTypeId);
const responseData = await handler(req);
expect(responseData.event_type.id).toEqual(eventTypeId);
});
test("Returns event type if user is in team associated with event type", async () => {
const eventTypeId = 123456;
const teamId = 9999;
const userId = 333333;
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
@@ -94,10 +118,10 @@ describe("GET /api/event-types/[id]", () => {
req.isSystemWideAdmin = false;
req.userId = userId;
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res._getData()).event_type.id).toEqual(eventTypeId);
const responseData = await handler(req);
expect(responseData.event_type.id).toEqual(eventTypeId);
expect(prismaMock.team.findFirst).toHaveBeenCalledWith({
where: {
id: teamId,
@@ -116,7 +140,7 @@ describe("GET /api/event-types/[id]", () => {
test("Returns event type if user is the event type owner", async () => {
const eventTypeId = 123456;
const userId = 333333;
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
@@ -134,10 +158,10 @@ describe("GET /api/event-types/[id]", () => {
req.isSystemWideAdmin = false;
req.userId = userId;
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res._getData()).event_type.id).toEqual(eventTypeId);
const responseData = await handler(req);
expect(responseData.event_type.id).toEqual(eventTypeId);
expect(prismaMock.team.findFirst).not.toHaveBeenCalled();
});
});
@@ -6,7 +6,6 @@ import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
import { APP_NAME } from "@calcom/lib/constants";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -26,11 +25,10 @@ const WebhooksViewServerWrapper = async () => {
redirect("/auth/login");
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
const caller = await createRouterCaller(webhookRouter);
const data = await caller.getByViewer();
return <WebhooksView data={data} isAdmin={isAdmin} />;
return <WebhooksView data={data} />;
};
export default WebhooksViewServerWrapper;
@@ -4,8 +4,8 @@ import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { validPassword } from "@calcom/features/auth/lib/validPassword";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
+2 -2
View File
@@ -4,8 +4,8 @@ import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import z from "zod";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import { isPasswordValid } from "@calcom/lib/auth/isPasswordValid";
import { emailRegex } from "@calcom/lib/emailSchema";
import { HttpError } from "@calcom/lib/http-error";
import slugify from "@calcom/lib/slugify";
@@ -7,10 +7,10 @@ import { useFieldArray, useFormContext } from "react-hook-form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import type { LocationObject } from "@calcom/app-store/locations";
import NoSSR from "@calcom/lib/components/NoSSR";
import { locationsResolver } from "@calcom/lib/event-types/utils/locationsResolver";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { LocationObject } from "@calcom/app-store/locations";
import type { AppCategories } from "@calcom/prisma/enums";
import type { EventTypeMetaDataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import { Avatar } from "@calcom/ui/components/avatar";
@@ -213,6 +213,7 @@ const ConfigureStepCardContent: FC<ConfigureStepCardProps> = (props) => {
);
const [submit, setSubmit] = useState(false);
const [mounted, setMounted] = useState(false);
const allUpdated = updatedEventTypesStatus.every((item) => item.every((iitem) => iitem.updated));
useEffect(() => {
@@ -222,7 +223,11 @@ const ConfigureStepCardContent: FC<ConfigureStepCardProps> = (props) => {
}
}, [submit, allUpdated, mainForSubmitRef]);
if (!formPortalRef?.current) {
useEffect(() => {
setMounted(true);
}, []);
if (!formPortalRef?.current || !mounted) {
return null;
}
+1 -1
View File
@@ -5,7 +5,7 @@ import React from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid";
import { isPasswordValid } from "@calcom/lib/auth/isPasswordValid";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { emailRegex } from "@calcom/lib/emailSchema";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -2,13 +2,13 @@ import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import type { LocationObject } from "@calcom/app-store/locations";
import { isConferencing as isConferencingApp } from "@calcom/app-store/utils";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
import { CAL_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import type { LocationObject } from "@calcom/app-store/locations";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -14,6 +14,7 @@ export const useCheckTeamBilling = (teamId?: number | null, isPlatformTeam?: boo
return data.data;
},
enabled: !!teamId && !!isPlatformTeam,
staleTime: 5000,
});
return isTeamBilledAlready;
+29 -2
View File
@@ -1,5 +1,7 @@
"use client";
import { useState, useCallback } from "react";
import {
DataTableProvider,
DataTableFilters,
@@ -7,6 +9,7 @@ import {
ColumnFilterType,
type FilterableColumn,
} from "@calcom/features/data-table";
import { useDataTable } from "@calcom/features/data-table/hooks/useDataTable";
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import {
AverageEventDurationChart,
@@ -27,11 +30,13 @@ import {
TimezoneBadge,
} from "@calcom/features/insights/components/booking";
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
import { DateTargetSelector, type DateTarget } from "@calcom/features/insights/filters/DateTargetSelector";
import { Download } from "@calcom/features/insights/filters/Download";
import { OrgTeamsFilter } from "@calcom/features/insights/filters/OrgTeamsFilter";
import { useInsightsBookings } from "@calcom/features/insights/hooks/useInsightsBookings";
import { useInsightsOrgTeams } from "@calcom/features/insights/hooks/useInsightsOrgTeams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
export default function InsightsPage({ timeZone }: { timeZone: string }) {
return (
@@ -49,10 +54,26 @@ const createdAtColumn: Extract<FilterableColumn, { type: ColumnFilterType.DATE_R
type: ColumnFilterType.DATE_RANGE,
};
const startTimeColumn: Extract<FilterableColumn, { type: ColumnFilterType.DATE_RANGE }> = {
id: "startTime",
title: "startTime",
type: ColumnFilterType.DATE_RANGE,
};
function InsightsPageContent() {
const { t } = useLocale();
const { table } = useInsightsBookings();
const { isAll, teamId, userId } = useInsightsOrgTeams();
const { removeFilter } = useDataTable();
const [dateTarget, _setDateTarget] = useState<"startTime" | "createdAt">("startTime");
const setDateTarget = useCallback(
(target: "startTime" | "createdAt") => {
_setDateTarget(target);
removeFilter(target === "startTime" ? "createdAt" : "startTime");
},
[_setDateTarget, removeFilter]
);
return (
<>
@@ -63,10 +84,16 @@ function InsightsPageContent() {
<DataTableFilters.AddFilterButton table={table} hideWhenFilterApplied />
<DataTableFilters.ActiveFilters table={table} />
<DataTableFilters.AddFilterButton table={table} variant="sm" showWhenFilterApplied />
<DataTableFilters.ClearFiltersButton exclude={["createdAt"]} />
<DataTableFilters.ClearFiltersButton exclude={["startTime", "createdAt"]} />
<div className="grow" />
<Download />
<DateRangeFilter column={createdAtColumn} />
<ButtonGroup combined>
<DateRangeFilter
column={dateTarget === "startTime" ? startTimeColumn : createdAtColumn}
options={{ convertToTimeZone: true }}
/>
<DateTargetSelector value={dateTarget as DateTarget} onChange={setDateTarget} />
</ButtonGroup>
<TimezoneBadge />
</div>
+5 -4
View File
@@ -1,13 +1,13 @@
{
"name": "@calcom/web",
"version": "5.7.1",
"version": "5.7.2",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
"analyze:server": "BUNDLE_ANALYZE=server next build",
"analyze:browser": "BUNDLE_ANALYZE=browser next build",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
"dev": "yarn copy-static && next dev --turbopack",
"dev": "turbo run copy-app-store-static && next dev --turbopack",
"dev:scan": "yarn dev & npx --yes react-scan@latest localhost:3000",
"dev:cron": "npx tsx cron-tester.ts",
"dev-https": "NODE_TLS_REJECT_UNAUTHORIZED=0 next dev --experimental-https",
@@ -16,8 +16,9 @@
"type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"sentry:release": "NODE_OPTIONS='--max-old-space-size=6144' node scripts/create-sentry-release.js",
"copy-static": "node scripts/copy-app-store-static.js",
"build": "yarn copy-static && next build && yarn sentry:release",
"copy-static": "turbo run copy-app-store-static",
"copy-app-store-static": "node scripts/copy-app-store-static.js",
"build": "turbo run copy-app-store-static && next build && yarn sentry:release",
"start": "next start",
"lint": "eslint . --ignore-path .gitignore",
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
@@ -16,8 +16,8 @@ test.describe("Duplicate API Calls Prevention", () => {
const trpcCalls: string[] = [];
const apiV2Calls: string[] = [];
// Intercept tRPC getSchedule calls - pattern matches trpc.viewer.slots.getSchedule.useQuery()
await page.route("**/api/trpc/viewer.slots.getSchedule**", async (route) => {
// Intercept tRPC getSchedule calls - pattern matches /api/trpc/slots/getSchedule
await page.route("**/api/trpc/slots/getSchedule**", async (route) => {
trpcCalls.push(route.request().url());
await route.continue();
});
@@ -33,14 +33,10 @@ test.describe("Duplicate API Calls Prevention", () => {
const totalCalls = trpcCalls.length + apiV2Calls.length;
if (totalCalls === 0) {
console.log("No API calls detected - environment may have issues preventing page load");
expect(totalCalls).toBeGreaterThanOrEqual(0);
} else {
expect(totalCalls).toBeLessThanOrEqual(1);
expect(trpcCalls.length).toBeLessThanOrEqual(1);
expect(apiV2Calls.length).toBeLessThanOrEqual(1);
}
expect(totalCalls).toBeGreaterThan(0);
expect(totalCalls).toBeLessThanOrEqual(1);
expect(trpcCalls.length).toBeLessThanOrEqual(1);
expect(apiV2Calls.length).toBeLessThanOrEqual(1);
});
test("should detect when schedule endpoints are called multiple times for team events", async ({
@@ -65,7 +61,7 @@ test.describe("Duplicate API Calls Prevention", () => {
const trpcCalls: string[] = [];
const apiV2Calls: string[] = [];
await page.route("**/api/trpc/viewer.slots.getSchedule**", async (route) => {
await page.route("**/api/trpc/slots/getSchedule**", async (route) => {
trpcCalls.push(route.request().url());
await route.continue();
});
@@ -81,14 +77,10 @@ test.describe("Duplicate API Calls Prevention", () => {
const totalCalls = trpcCalls.length + apiV2Calls.length;
if (totalCalls === 0) {
console.log("No API calls detected - environment may have issues preventing page load");
expect(totalCalls).toBeGreaterThanOrEqual(0);
} else {
expect(totalCalls).toBeLessThanOrEqual(1);
expect(trpcCalls.length).toBeLessThanOrEqual(1);
expect(apiV2Calls.length).toBeLessThanOrEqual(1);
}
expect(totalCalls).toBeGreaterThan(0);
expect(totalCalls).toBeLessThanOrEqual(1);
expect(trpcCalls.length).toBeLessThanOrEqual(1);
expect(apiV2Calls.length).toBeLessThanOrEqual(1);
});
test("should detect when schedule endpoints are called multiple times for organization team events", async ({
@@ -117,7 +109,7 @@ test.describe("Duplicate API Calls Prevention", () => {
const trpcCalls: string[] = [];
const apiV2Calls: string[] = [];
await page.route("**/api/trpc/viewer.slots.getSchedule**", async (route) => {
await page.route("**/api/trpc/slots/getSchedule**", async (route) => {
trpcCalls.push(route.request().url());
await route.continue();
});
@@ -133,13 +125,9 @@ test.describe("Duplicate API Calls Prevention", () => {
const totalCalls = trpcCalls.length + apiV2Calls.length;
if (totalCalls === 0) {
console.log("No API calls detected - environment may have issues preventing page load");
expect(totalCalls).toBeGreaterThanOrEqual(0);
} else {
expect(totalCalls).toBeLessThanOrEqual(1);
expect(trpcCalls.length).toBeLessThanOrEqual(1);
expect(apiV2Calls.length).toBeLessThanOrEqual(1);
}
expect(totalCalls).toBeGreaterThan(0);
expect(totalCalls).toBeLessThanOrEqual(1);
expect(trpcCalls.length).toBeLessThanOrEqual(1);
expect(apiV2Calls.length).toBeLessThanOrEqual(1);
});
});
+2 -2
View File
@@ -775,10 +775,10 @@ test.describe("Out of office", () => {
//Default filter 'Last 7 Days' when DateRange Filter is selected
await test.step("Default filter - 'Last 7 Days'", async () => {
await addFilter(page, "dateRange");
const entriesListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await addFilter(page, "dateRange");
await entriesListRespPromise;
//1 OOO record should be visible for member3, end=currentDate-4days
@@ -836,10 +836,10 @@ test.describe("Out of office", () => {
//Select 'Last 30 Days'
await test.step("select 'Last 30 Days'", async () => {
await addFilter(page, "dateRange");
const entriesListRespPromise1 = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await addFilter(page, "dateRange");
await entriesListRespPromise1;
const entriesListRespPromise2 = page.waitForResponse(
@@ -3498,6 +3498,11 @@
"pbac_desc_view_workflows": "View existing workflows and their configurations",
"pbac_desc_update_workflows": "Edit and modify workflow settings",
"pbac_desc_delete_workflows": "Remove workflows from the system",
"pbac_resource_webhook": "Webhook",
"pbac_desc_create_webhooks": "Create webhooks",
"pbac_desc_view_webhooks": "View webhooks",
"pbac_desc_update_webhooks": "Update webhooks",
"pbac_desc_delete_webhooks": "Delete webhooks",
"pbac_desc_manage_workflows": "Full management access to all workflows",
"pbac_desc_create_event_types": "Create event types",
"pbac_desc_view_event_types": "View event types",
@@ -3689,6 +3694,10 @@
"before_scheduled_start_time": "Before scheduled start time",
"cancel_booking_acknowledge_no_show_fee": "I acknowledge that by cancelling the booking within {{timeValue}} {{timeUnit}} of the start time I will be charged the no show fee of {{amount, currency}}",
"contact_organizer": "If you have any questions, please contact the organizer.",
"booking_time_option": "Booking time",
"booking_time_option_description": "When the booking is scheduled (start to end)",
"created_at_option": "Created at",
"created_at_option_description": "When the booking was originally created",
"call_details": "Call Details",
"call_id": "Call ID",
"call_information": "Call Information",
+1 -1
View File
@@ -20,7 +20,7 @@ const copyAppStoreStatic = () => {
fs.mkdirSync(destDir, { recursive: true });
}
// Copy file to destination
// Copy file to destination (Turborepo caching handles change detection)
const destPath = path.join(destDir, fileName);
fs.copyFileSync(file, destPath);
console.log(`Copied ${file} to ${destPath}`);
+1
View File
@@ -86,6 +86,7 @@
"devDependencies": {
"@changesets/changelog-github": "^0.5.1",
"@changesets/cli": "2.29.4",
"@faker-js/faker": "9.2.0",
"@jetstreamapp/soql-parser-js": "^6.1.0",
"@playwright/test": "^1.45.3",
"@snaplet/copycat": "^4.1.0",
@@ -16,6 +16,7 @@ import createUsersAndConnectToOrg from "@calcom/features/ee/dsync/lib/users/crea
import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/ImpersonationProvider";
import { getOrgFullOrigin, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { clientSecretVerifier, hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { isPasswordValid } from "@calcom/lib/auth/isPasswordValid";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import {
GOOGLE_CALENDAR_SCOPES,
@@ -46,7 +47,6 @@ import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
import { getOrgUsernameFromEmail } from "../signup/utils/getOrgUsernameFromEmail";
import { ErrorCode } from "./ErrorCode";
import { dub } from "./dub";
import { isPasswordValid } from "./isPasswordValid";
import CalComAdapter from "./next-auth-custom-adapter";
import { verifyPassword } from "./verifyPassword";
@@ -2,12 +2,12 @@ import { cookies, headers } from "next/headers";
import { NextResponse } from "next/server";
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
import { prefillAvatar } from "@calcom/features/auth/signup/utils/prefillAvatar";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getLocaleFromRequest } from "@calcom/lib/getLocaleFromRequest";
import { HttpError } from "@calcom/lib/http-error";
@@ -1,9 +1,9 @@
import { NextResponse } from "next/server";
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { isUsernameReservedDueToMigration } from "@calcom/lib/server/username";
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
import classNames from "@calcom/ui/classNames";
import { Badge } from "@calcom/ui/components/badge";
import { Button, buttonClasses } from "@calcom/ui/components/button";
@@ -29,6 +30,7 @@ import {
getDateRangeFromPreset,
type PresetOption,
} from "../../lib/dateRange";
import { preserveLocalTime } from "../../lib/preserveLocalTime";
import type { FilterableColumn, DateRangeFilterOptions } from "../../lib/types";
import { ZDateRangeFilterValue, ColumnFilterType } from "../../lib/types";
import { useFilterPopoverOpen } from "./useFilterPopoverOpen";
@@ -48,9 +50,8 @@ export const DateRangeFilter = ({
}: DateRangeFilterProps) => {
const { open, onOpenChange } = useFilterPopoverOpen(column.id);
const filterValue = useFilterValue(column.id, ZDateRangeFilterValue);
const { updateFilter, removeFilter } = useDataTable();
const { updateFilter, removeFilter, timeZone: givenTimeZone } = useDataTable();
const range = options?.range ?? "past";
const endOfDay = options?.endOfDay ?? false;
const forceCustom = range === "custom";
const forcePast = range === "past";
@@ -70,6 +71,19 @@ export const DateRangeFilter = ({
: DEFAULT_PRESET
);
const convertTimestamp = useCallback(
(timestamp: string) => {
if (!options?.convertToTimeZone) {
return timestamp;
}
if (!givenTimeZone || CURRENT_TIMEZONE === givenTimeZone) {
return timestamp;
}
return preserveLocalTime(timestamp, CURRENT_TIMEZONE, givenTimeZone);
},
[options?.convertToTimeZone, givenTimeZone]
);
const updateValues = useCallback(
({ preset, startDate, endDate }: { preset: PresetOption; startDate?: Dayjs; endDate?: Dayjs }) => {
setSelectedPreset(preset);
@@ -80,14 +94,14 @@ export const DateRangeFilter = ({
updateFilter(column.id, {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: startDate.toDate().toISOString(),
endDate: (endOfDay ? endDate.endOf("day") : endDate).toDate().toISOString(),
startDate: convertTimestamp(startDate.toDate().toISOString()),
endDate: convertTimestamp(endDate.toDate().toISOString()),
preset: preset.value,
},
});
}
},
[column.id, endOfDay]
[column.id, updateFilter, convertTimestamp]
);
useEffect(() => {
@@ -126,10 +140,12 @@ export const DateRangeFilter = ({
startDate?: Date | undefined;
endDate?: Date | undefined;
}) => {
// DateRangePicker returns the beginning of the day,
// so we need to update `endDate` to the end of the day.
updateValues({
preset: CUSTOM_PRESET,
startDate: startDate ? dayjs(startDate) : getDefaultStartDate(),
endDate: endDate ? dayjs(endDate) : undefined,
endDate: endDate ? dayjs(endDate).add(1, "day").subtract(1, "millisecond") : undefined,
});
};
@@ -1,28 +1,20 @@
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
import { preserveLocalTime } from "../lib/preserveLocalTime";
import { useDataTable } from "./useDataTable";
/**
* Converts a timestamp to maintain the same local time in a different timezone.
*
* For example, if it's midnight (00:00) in Paris time:
* - Input : "2025-05-22T22:00:00.000Z" (Midnight/00:00 in Paris)
* - Output: "2025-05-22T15:00:00.000Z" (Midnight/00:00 in Seoul)
*
* This ensures that times like midnight (00:00) or end of day (23:59)
* remain at those exact local times when converting between timezones.
* The output timestamp is based on the timezone in the user's profile settings.
* Fore more info, read packages/features/data-table/lib/preserveLocalTime.ts
*/
export function useChangeTimeZoneWithPreservedLocalTime(isoString: string) {
const { timeZone: profileTimeZone } = useDataTable();
return useMemo(() => {
const currentTimeZone = dayjs.tz.guess();
if (!profileTimeZone || currentTimeZone === profileTimeZone) {
if (!profileTimeZone || CURRENT_TIMEZONE === profileTimeZone) {
return isoString;
}
return preserveLocalTime(isoString, currentTimeZone, profileTimeZone);
return preserveLocalTime(isoString, CURRENT_TIMEZONE, profileTimeZone);
}, [isoString, profileTimeZone]);
}
@@ -10,6 +10,12 @@ import dayjs from "@calcom/dayjs";
* This ensures that times like midnight (00:00) or end of day (23:59)
* remain at those exact local times when converting between timezones.
* The output timestamp is based on the timezone in the user's profile settings.
*
* For example, the profile timezone is Asia/Seoul,
* but the current user is in Europe/Paris.
* `Date` pickers will normally emit timestamps in the user's local timezone. (00:00:00 ~ 23:59:59 in Paris time)
* but what we really want is to fetch the data based on the user's profile timezone. (00:00:00 ~ 23:59:59 in Seoul time)
* That's why we need to convert the timestamp to the user's profile timezone.
*/
export const preserveLocalTime = (isoString: string, originalTimeZone: string, targetTimeZone: string) => {
// Parse the input time
+1 -1
View File
@@ -116,7 +116,7 @@ export const ZFilterValue = z.union([
export type DateRangeFilterOptions = {
range?: "past" | "custom";
endOfDay?: boolean;
convertToTimeZone?: boolean;
};
export type TextFilterOptions = {
@@ -1,5 +1,6 @@
"use client";
import { useSession } from "next-auth/react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, type FC } from "react";
import { IntercomProvider } from "react-use-intercom";
@@ -31,6 +32,9 @@ const Provider: FC<{ children: React.ReactNode }> = ({ children }) => {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const { data: session } = useSession();
const isBeingImpersonated = !!session?.user?.impersonatedBy?.id;
const shouldOpenSupport =
pathname === "/event-types" && (searchParams?.has("openPlain") || searchParams?.has("openSupport"));
@@ -65,7 +69,7 @@ const Provider: FC<{ children: React.ReactNode }> = ({ children }) => {
const isOnboardingPage = pathname?.startsWith("/getting-started");
const isCalVideoPage = pathname?.startsWith("/video/");
if (isOnboardingPage || isCalVideoPage) {
if (isOnboardingPage || isCalVideoPage || isBeingImpersonated) {
return <>{children}</>;
}
@@ -3,6 +3,7 @@ import type { Dispatch, SetStateAction } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { z } from "zod";
import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData";
import { useAtomsContext } from "@calcom/atoms/hooks/useAtomsContext";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import {
@@ -31,8 +32,6 @@ import type {
SettingsToggleClassNames,
} from "@calcom/features/eventtypes/lib/types";
import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
import type { fieldSchema } from "@calcom/features/form-builder/schema";
import type { EditableSchema } from "@calcom/features/form-builder/schema";
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
import {
DEFAULT_LIGHT_BRAND_COLOR,
@@ -42,11 +41,12 @@ import {
} from "@calcom/lib/constants";
import { generateHashedLink } from "@calcom/lib/generateHashedLink";
import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData";
import { extractHostTimezone } from "@calcom/lib/hashedLinksUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { Prisma } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import type { EditableSchema } from "@calcom/prisma/zod-utils";
import type { fieldSchema } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Alert } from "@calcom/ui/components/alert";
@@ -365,6 +365,11 @@ const InstantMeetingWebhooks = ({ eventType }: { eventType: EventTypeSetup }) =>
setEditModalOpen(true);
setWebhookToEdit(webhook);
}}
// TODO (SEAN): Implement Permissions here when we have event-types PR merged
permissions={{
canEditWebhook: !webhookLockedStatus.disabled,
canDeleteWebhook: !webhookLockedStatus.disabled,
}}
/>
);
})}
@@ -8,6 +8,7 @@ import type {
import Widgets from "@calcom/app-store/routing-forms/components/react-awesome-query-builder/widgets";
import PhoneInput from "@calcom/features/components/phone-input";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { fieldSchema, variantsConfigSchema, FieldType } from "@calcom/prisma/zod-utils";
import { AddressInput } from "@calcom/ui/components/address";
import { InfoBadge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
@@ -18,7 +19,6 @@ import { Tooltip } from "@calcom/ui/components/tooltip";
import { ComponentForField } from "./FormBuilderField";
import { propsTypes } from "./propsTypes";
import type { fieldSchema, FieldType, variantsConfigSchema } from "./schema";
import { preprocessNameFieldDataWithVariant } from "./utils";
export const isValidValueProp: Record<Component["propsType"], (val: unknown) => boolean> = {
@@ -4,13 +4,14 @@ import type { SubmitHandler, UseFormReturn } from "react-hook-form";
import { Controller, useFieldArray, useForm, useFormContext } from "react-hook-form";
import type { z } from "zod";
import { ZodError } from "zod";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import { getCurrencySymbol } from "@calcom/app-store/_utils/payments/currencyConversions";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import { markdownToSafeHTMLClient } from "@calcom/lib/markdownToSafeHTMLClient";
import turndown from "@calcom/lib/turndownService";
import { excludeOrRequireEmailSchema } from "@calcom/prisma/zod-utils";
import classNames from "@calcom/ui/classNames";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
@@ -31,7 +32,7 @@ import { showToast } from "@calcom/ui/components/toast";
import { fieldTypesConfigMap } from "./fieldTypes";
import { fieldsThatSupportLabelAsSafeHtml } from "./fieldsThatSupportLabelAsSafeHtml";
import { type fieldsSchema, excludeOrRequireEmailSchema } from "./schema";
import type { fieldsSchema } from "./schema";
import { getFieldIdentifier } from "./utils/getFieldIdentifier";
import { getConfig as getVariantsConfig } from "./utils/variantsConfig";
@@ -571,6 +572,7 @@ function FieldEditDialog({
paymentCurrency: string;
}) {
const { t } = useLocale();
const isPlatform = useIsPlatform();
const fieldForm = useForm<RhfFormField>({
defaultValues: dialog.data || {},
//resolver: zodResolver(fieldSchema),
@@ -648,7 +650,7 @@ function FieldEditDialog({
{...fieldForm.register("disableOnPrefill", { setValueAs: Boolean })}
/>
<div>
{formFieldType === "boolean" ? (
{formFieldType === "boolean" && !isPlatform ? (
<CheckboxFieldLabel fieldForm={fieldForm} />
) : (
<InputField
+3 -1
View File
@@ -1,7 +1,9 @@
import type z from "zod";
import type { FieldType } from "@calcom/prisma/zod-utils";
import { propsTypes } from "./propsTypes";
import type { FieldType, fieldTypeConfigSchema } from "./schema";
import type { fieldTypeConfigSchema } from "./schema";
const configMap: Record<FieldType, Omit<z.infer<typeof fieldTypeConfigSchema>, "propsType">> = {
// This won't be stored in DB. It allows UI to be configured from the codebase for all existing booking fields stored in DB as well
@@ -1,4 +1,4 @@
import type { FieldType } from "./schema";
import type { FieldType } from "@calcom/prisma/zod-utils";
/**
* Once a component supports `labelAsSafeHtml`, add it's field's type here
+1 -190
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
import { getValidRhfFieldName } from "@calcom/lib/getValidRhfFieldName";
import { fieldSchema, fieldTypeEnum, variantsConfigSchema, type FieldType } from "@calcom/prisma/zod-utils";
import { fieldTypesConfigMap } from "./fieldTypes";
import { preprocessNameFieldDataWithVariant } from "./utils";
@@ -8,154 +8,6 @@ import { getConfig as getVariantsConfig } from "./utils/variantsConfig";
const nonEmptyString = () => z.string().refine((value: string) => value.trim().length > 0);
const fieldTypeEnum = z.enum([
"name",
"text",
"textarea",
"number",
"email",
"phone",
"address",
"multiemail",
"select",
"multiselect",
"checkbox",
"radio",
"radioInput",
"boolean",
"url",
]);
export type FieldType = z.infer<typeof fieldTypeEnum>;
export const EditableSchema = z.enum([
"system", // Can't be deleted, can't be hidden, name can't be edited, can't be marked optional
"system-but-optional", // Can't be deleted. Name can't be edited. But can be hidden or be marked optional
"system-but-hidden", // Can't be deleted, name can't be edited, will be shown
"user", // Fully editable
"user-readonly", // All fields are readOnly.
]);
export const excludeOrRequireEmailSchema = z.string().superRefine((val, ctx) => {
const allDomains = val.split(",").map((dom) => dom.trim());
const regex = /^(?:@?[a-z0-9-]+(?:\.[a-z]{2,})?)?(?:@[a-z0-9-]+\.[a-z]{2,})?$/;
/*
Valid patterns - [ example, example.anything, anyone@example.anything ]
Invalid patterns - Patterns involving capital letter [ Example, Example.anything, Anyone@example.anything ]
*/
const isValid = !allDomains.some((domain) => !regex.test(domain));
if (!isValid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Enter valid domain or email",
});
}
});
const baseFieldSchema = z.object({
name: z.string().transform(getValidRhfFieldName),
type: fieldTypeEnum,
// TODO: We should make at least one of `defaultPlaceholder` and `placeholder` required. Do the same for label.
label: z.string().optional(),
labelAsSafeHtml: z.string().optional(),
/**
* It is the default label that will be used when a new field is created.
* Note: It belongs in FieldsTypeConfig, so that changing defaultLabel in code can work for existing fields as well(for fields that are using the default label).
* Supports translation
*/
defaultLabel: z.string().optional(),
placeholder: z.string().optional(),
/**
* It is the default placeholder that will be used when a new field is created.
* Note: Same as defaultLabel, it belongs in FieldsTypeConfig
* Supports translation
*/
defaultPlaceholder: z.string().optional(),
required: z.boolean().default(false).optional(),
/**
* It is the list of options that is valid for a certain type of fields.
*
*/
options: z
.array(
z.object({
label: z.string(),
value: z.string(),
price: z.coerce.number().min(0).optional(),
})
)
.optional(),
/**
* This is an alternate way to specify options when the options are stored elsewhere. Form Builder expects options to be present at `dataStore[getOptionsAt]`
* This allows keeping a single source of truth in DB.
*/
getOptionsAt: z.string().optional(),
/**
* For `radioInput` type of questions, it stores the input that is shown based on the user option selected.
* e.g. If user is given a list of locations and he selects "Phone", then he will be shown a phone input
*/
optionsInputs: z
.record(
z.object({
// Support all types as needed
// Must be a subset of `fieldTypeEnum`.TODO: Enforce it in TypeScript
type: z.enum(["address", "phone", "text"]),
required: z.boolean().optional(),
placeholder: z.string().optional(),
})
)
.optional(),
/**
* It is the minimum number of characters that can be entered in the field.
* It is used for types with `supportsLengthCheck= true`.
* @default 0
* @requires supportsLengthCheck = true
*/
minLength: z.number().optional(),
/**
* It is the maximum number of characters that can be entered in the field.
* It is used for types with `supportsLengthCheck= true`.
* @requires supportsLengthCheck = true
*/
maxLength: z.number().optional(),
// Emails that needs to be excluded
excludeEmails: excludeOrRequireEmailSchema.optional(),
// Emails that need to be required
requireEmails: excludeOrRequireEmailSchema.optional(),
// Price associated with the field which works like addons which users can add to the booking
price: z.coerce.number().min(0).optional(),
});
export const variantsConfigSchema = z.object({
variants: z.record(
z.object({
/**
* Variant Fields schema for a variant of the main field.
* It doesn't support non text fields as of now
**/
fields: baseFieldSchema
.omit({
defaultLabel: true,
defaultPlaceholder: true,
options: true,
getOptionsAt: true,
optionsInputs: true,
})
.array(),
})
),
});
export type ALL_VIEWS = "ALL_VIEWS";
// It is the config that is specific to a type and doesn't make sense in all fields individually. Any field with the type will automatically inherit this config.
@@ -238,47 +90,6 @@ export const fieldTypeConfigSchema = z
return true;
});
/**
* Main field Schema
*/
export const fieldSchema = baseFieldSchema.merge(
z.object({
variant: z.string().optional(),
variantsConfig: variantsConfigSchema.optional(),
views: z
.object({
label: z.string(),
id: z.string(),
description: z.string().optional(),
})
.array()
.optional(),
/**
* It is used to hide fields such as location when there are less than two options
*/
hideWhenJustOneOption: z.boolean().default(false).optional(),
hidden: z.boolean().optional(),
editable: EditableSchema.default("user").optional(),
sources: z
.array(
z.object({
// Unique ID for the `type`. If type is workflow, it's the workflow ID
id: z.string(),
type: z.union([z.literal("user"), z.literal("system"), z.string()]),
label: z.string(),
editUrl: z.string().optional(),
// Mark if a field is required by this source or not. This allows us to set `field.required` based on all the sources' fieldRequired value
fieldRequired: z.boolean().optional(),
})
)
.optional(),
disableOnPrefill: z.boolean().default(false).optional(),
})
);
export const fieldsSchema = z.array(fieldSchema);
export const fieldTypesSchemaMap: Partial<
@@ -1,9 +1,9 @@
import type z from "zod";
import type { useLocale } from "@calcom/lib/hooks/useLocale";
import type { fieldSchema } from "@calcom/prisma/zod-utils";
import { fieldTypesConfigMap } from "../fieldTypes";
import type { fieldSchema } from "../schema";
type ConfigVariants = NonNullable<ReturnType<typeof getConfig>>["variants"];
type Field = z.infer<typeof fieldSchema>;
@@ -36,7 +36,7 @@ type EventTrendsData = RouterOutputs["viewer"]["insights"]["eventTrends"][number
const CustomTooltip = ({
active,
payload,
label,
label: _label,
}: {
active?: boolean;
payload?: Array<{
@@ -3,7 +3,6 @@
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { useChangeTimeZoneWithPreservedLocalTime } from "@calcom/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
@@ -16,18 +15,6 @@ export const LeastCompletedTeamMembersTable = () => {
const { t } = useLocale();
let insightsBookingParams = useInsightsBookingParameters();
const currentTime = useChangeTimeZoneWithPreservedLocalTime(
useMemo(() => {
return dayjs().toISOString();
}, [])
);
// booking with endDate < now is "accepted" booking
insightsBookingParams = {
...insightsBookingParams,
endDate: currentTime,
};
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLeastCompletedBookings.useQuery(
insightsBookingParams,
{
@@ -3,7 +3,6 @@
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { useChangeTimeZoneWithPreservedLocalTime } from "@calcom/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
@@ -16,18 +15,6 @@ export const MostCompletedTeamMembersTable = () => {
const { t } = useLocale();
let insightsBookingParams = useInsightsBookingParameters();
const currentTime = useChangeTimeZoneWithPreservedLocalTime(
useMemo(() => {
return dayjs().toISOString();
}, [])
);
// booking with endDate < now is "accepted" booking
insightsBookingParams = {
...insightsBookingParams,
endDate: currentTime,
};
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostCompletedBookings.useQuery(
insightsBookingParams,
{
@@ -6,7 +6,6 @@ import { useDataTable } from "@calcom/features/data-table";
import NoSSR from "@calcom/lib/components/NoSSR";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
import { Badge } from "@calcom/ui/components/badge";
import { Icon } from "@calcom/ui/components/icon";
import { Tooltip } from "@calcom/ui/components/tooltip";
@@ -41,9 +40,7 @@ const TimezoneBadgeContent = () => {
return (
<Tooltip content={timezoneData.tooltipContent}>
<Badge variant="gray" size="sm" data-testid="timezone-mismatch-badge">
<Icon name="info" />
</Badge>
<Icon name="info" data-testid="timezone-mismatch-badge" className="text-subtle" />
</Tooltip>
);
};
@@ -105,7 +105,7 @@ export function RoutingFormResponsesTable() {
// this also prevents user from clearing the routing form filter
updateFilter("formId", { type: ColumnFilterType.SINGLE_SELECT, data: newRoutingFormId });
}
}, [table, getInsightsFacetedUniqueValues, routingFormId]);
}, [table, getInsightsFacetedUniqueValues, routingFormId, updateFilter]);
return (
<>
@@ -0,0 +1,78 @@
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Command, CommandList, CommandItem } from "@calcom/ui/components/command";
import { Icon } from "@calcom/ui/components/icon";
import { Popover, PopoverTrigger, PopoverContent } from "@calcom/ui/components/popover";
export type DateTarget = "startTime" | "createdAt";
interface DateTargetOption {
label: string;
description: string;
value: DateTarget;
}
interface DateTargetSelectorProps {
value: DateTarget;
onChange: (value: DateTarget) => void;
}
export const DateTargetSelector = ({ value, onChange }: DateTargetSelectorProps) => {
const { t } = useLocale();
const [open, setOpen] = useState(false);
const options: DateTargetOption[] = [
{
label: t("booking_time_option"),
description: t("booking_time_option_description"),
value: "startTime",
},
{
label: t("created_at_option"),
description: t("created_at_option_description"),
value: "createdAt",
},
];
const selectedOption = options.find((option) => option.value === value) || options[0];
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="icon"
color="secondary"
StartIcon="sliders-horizontal"
role="combobox"
aria-expanded={open}>
<span className="sr-only">{selectedOption.label}</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="end">
<Command>
<CommandList>
{options.map((option) => (
<CommandItem
key={option.value}
onSelect={() => {
onChange(option.value);
setOpen(false);
}}
className="flex items-center gap-2 px-4 py-3">
<div>
<div className="font-medium">{option.label}</div>
<div className="text-muted-foreground text-sm">{option.description}</div>
</div>
{selectedOption.value === option.value && (
<Icon name="check" className="text-primary-foreground h-4 w-4" />
)}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
@@ -15,6 +15,7 @@ import {
import { showToast, showProgressToast, hideProgressToast } from "@calcom/ui/components/toast";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { extractDateRangeFromColumnFilters } from "../../lib/bookingUtils";
type RawData = RouterOutputs["viewer"]["insights"]["rawData"]["data"][number];
@@ -23,7 +24,7 @@ const BATCH_SIZE = 100;
const Download = () => {
const { t } = useLocale();
const insightsBookingParams = useInsightsBookingParameters();
const { startDate, endDate } = insightsBookingParams;
const { startDate, endDate } = extractDateRangeFromColumnFilters(insightsBookingParams.columnFilters);
const [isDownloading, setIsDownloading] = useState(false);
const utils = trpc.useUtils();
@@ -1,12 +1,14 @@
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { ZDateRangeFilterValue } from "@calcom/features/data-table";
import { useChangeTimeZoneWithPreservedLocalTime } from "@calcom/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime";
import { useColumnFilters } from "@calcom/features/data-table/hooks/useColumnFilters";
import { useDataTable } from "@calcom/features/data-table/hooks/useDataTable";
import { useFilterValue } from "@calcom/features/data-table/hooks/useFilterValue";
import { getDefaultStartDate, getDefaultEndDate } from "@calcom/features/data-table/lib/dateRange";
import {
getDefaultStartDate,
getDefaultEndDate,
DEFAULT_PRESET,
} from "@calcom/features/data-table/lib/dateRange";
import { ColumnFilterType, type ColumnFilter } from "@calcom/features/data-table/lib/types";
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
import { useInsightsOrgTeams } from "./useInsightsOrgTeams";
@@ -14,33 +16,35 @@ import { useInsightsOrgTeams } from "./useInsightsOrgTeams";
export function useInsightsBookingParameters() {
const { scope, selectedTeamId } = useInsightsOrgTeams();
const { timeZone } = useDataTable();
const createdAtRange = useFilterValue("createdAt", ZDateRangeFilterValue)?.data;
// TODO for future: this preserving local time & startOf & endOf should be handled
// from DateRangeFilter out of the box.
// When we do it, we also need to remove those timezone handling logic from the backend side at the same time.
const startDate = useChangeTimeZoneWithPreservedLocalTime(
useMemo(() => {
return dayjs(createdAtRange?.startDate ?? getDefaultStartDate().toISOString())
.startOf("day")
.toISOString();
}, [createdAtRange?.startDate])
);
const endDate = useChangeTimeZoneWithPreservedLocalTime(
useMemo(() => {
return dayjs(createdAtRange?.endDate ?? getDefaultEndDate().toISOString())
.endOf("day")
.toISOString();
}, [createdAtRange?.endDate])
);
const columnFilters = useColumnFilters({ exclude: ["createdAt"] });
const columnFilters = useColumnFilters();
const columnFiltersWithDefaultDateRange = useMemo(() => {
const hasDateRangeFilter = columnFilters.find(
(filter) => filter.id === "startTime" || filter.id === "createdAt"
);
if (hasDateRangeFilter) {
return columnFilters;
} else {
return [
...columnFilters,
{
id: "startTime",
value: {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: dayjs(getDefaultStartDate().toISOString()).startOf("day").toISOString(),
endDate: dayjs(getDefaultEndDate().toISOString()).endOf("day").toISOString(),
preset: DEFAULT_PRESET.value,
},
},
} satisfies ColumnFilter,
];
}
}, [columnFilters]);
return {
scope,
selectedTeamId,
startDate,
endDate,
timeZone: timeZone || CURRENT_TIMEZONE,
columnFilters,
columnFilters: columnFiltersWithDefaultDateRange,
};
}
@@ -0,0 +1,538 @@
import { describe, it, expect } from "vitest";
import { ColumnFilterType, type ColumnFilter } from "@calcom/features/data-table/lib/types";
import { extractDateRangeFromColumnFilters, replaceDateRangeColumnFilter } from "../bookingUtils";
describe("extractDateRangeFromColumnFilters", () => {
const mockStartTimeFilter: ColumnFilter = {
id: "startTime",
value: {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-31T23:59:59.999Z",
preset: "thisMonth",
},
},
};
const mockCreatedAtFilter: ColumnFilter = {
id: "createdAt",
value: {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: "2024-02-01T00:00:00.000Z",
endDate: "2024-02-29T23:59:59.999Z",
preset: "lastMonth",
},
},
};
const mockTextFilter: ColumnFilter = {
id: "status",
value: {
type: ColumnFilterType.TEXT,
data: {
operator: "equals" as const,
operand: "confirmed",
},
},
};
const mockSingleSelectFilter: ColumnFilter = {
id: "eventType",
value: {
type: ColumnFilterType.SINGLE_SELECT,
data: "meeting",
},
};
describe("successful extraction", () => {
it("should extract date range from startTime filter", () => {
const result = extractDateRangeFromColumnFilters([mockStartTimeFilter, mockTextFilter]);
expect(result).toEqual({
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-31T23:59:59.999Z",
dateTarget: "startTime",
});
});
it("should extract date range from createdAt filter", () => {
const result = extractDateRangeFromColumnFilters([mockCreatedAtFilter, mockSingleSelectFilter]);
expect(result).toEqual({
startDate: "2024-02-01T00:00:00.000Z",
endDate: "2024-02-29T23:59:59.999Z",
dateTarget: "createdAt",
});
});
it("should find startTime filter when it comes after other filters", () => {
const result = extractDateRangeFromColumnFilters([
mockTextFilter,
mockStartTimeFilter,
mockSingleSelectFilter,
]);
expect(result).toEqual({
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-31T23:59:59.999Z",
dateTarget: "startTime",
});
});
it("should ignore non-date-range filters", () => {
const result = extractDateRangeFromColumnFilters([
mockTextFilter,
mockSingleSelectFilter,
mockStartTimeFilter,
]);
expect(result).toEqual({
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-31T23:59:59.999Z",
dateTarget: "startTime",
});
});
});
describe("error cases", () => {
it("should throw error when no column filters provided", () => {
expect(() => extractDateRangeFromColumnFilters()).toThrow("No date range filter found");
});
it("should throw error when column filters is undefined", () => {
expect(() => extractDateRangeFromColumnFilters(undefined)).toThrow("No date range filter found");
});
it("should handle empty column filters array", () => {
expect(() => extractDateRangeFromColumnFilters([])).toThrow("No date range filter found");
});
it("should throw error when no date range filters exist", () => {
expect(() => extractDateRangeFromColumnFilters([mockTextFilter, mockSingleSelectFilter])).toThrow(
"No date range filter found"
);
});
it("should throw error when date range filter has missing startDate", () => {
const filterWithMissingStartDate: ColumnFilter = {
id: "startTime",
value: {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: null,
endDate: "2024-01-31T23:59:59.999Z",
preset: "thisMonth",
},
},
};
expect(() => extractDateRangeFromColumnFilters([filterWithMissingStartDate])).toThrow(
"No date range filter found"
);
});
it("should throw error when date range filter has missing endDate", () => {
const filterWithMissingEndDate: ColumnFilter = {
id: "createdAt",
value: {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: "2024-01-01T00:00:00.000Z",
endDate: null,
preset: "thisMonth",
},
},
};
expect(() => extractDateRangeFromColumnFilters([filterWithMissingEndDate])).toThrow(
"No date range filter found"
);
});
it("should throw error when date range filter has both dates missing", () => {
const filterWithMissingDates: ColumnFilter = {
id: "startTime",
value: {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: null,
endDate: null,
preset: "thisMonth",
},
},
};
expect(() => extractDateRangeFromColumnFilters([filterWithMissingDates])).toThrow(
"No date range filter found"
);
});
it("should throw error when startTime/createdAt filter is not DATE_RANGE type", () => {
const invalidStartTimeFilter: ColumnFilter = {
id: "startTime",
value: {
type: ColumnFilterType.TEXT,
data: {
operator: "equals" as const,
operand: "some-date",
},
},
};
expect(() => extractDateRangeFromColumnFilters([invalidStartTimeFilter])).toThrow(
"No date range filter found"
);
});
});
describe("edge cases", () => {
it("should work with only one date range filter among many others", () => {
const numberFilter: ColumnFilter = {
id: "number-filter",
value: {
type: ColumnFilterType.NUMBER,
data: { operator: "gt" as const, operand: 5 },
},
};
const anotherTextFilter: ColumnFilter = {
id: "another-text",
value: {
type: ColumnFilterType.TEXT,
data: { operator: "contains" as const, operand: "test" },
},
};
const manyFilters = [
mockTextFilter,
mockSingleSelectFilter,
anotherTextFilter,
mockStartTimeFilter,
numberFilter,
];
const result = extractDateRangeFromColumnFilters(manyFilters);
expect(result).toEqual({
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-31T23:59:59.999Z",
dateTarget: "startTime",
});
});
});
});
describe("replaceDateRangeColumnFilter", () => {
const mockStartTimeFilter: ColumnFilter = {
id: "startTime",
value: {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-31T23:59:59.999Z",
preset: "thisMonth",
},
},
};
const mockCreatedAtFilter: ColumnFilter = {
id: "createdAt",
value: {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: "2024-02-01T00:00:00.000Z",
endDate: "2024-02-29T23:59:59.999Z",
preset: "lastMonth",
},
},
};
const mockTextFilter: ColumnFilter = {
id: "status",
value: {
type: ColumnFilterType.TEXT,
data: {
operator: "equals" as const,
operand: "confirmed",
},
},
};
const mockSingleSelectFilter: ColumnFilter = {
id: "eventType",
value: {
type: ColumnFilterType.SINGLE_SELECT,
data: "meeting",
},
};
const newStartDate = "2024-03-01T00:00:00.000Z";
const newEndDate = "2024-03-31T23:59:59.999Z";
describe("successful replacement", () => {
it("should replace startTime filter with new dates", () => {
const columnFilters = [mockStartTimeFilter, mockTextFilter];
const result = replaceDateRangeColumnFilter({
columnFilters,
newStartDate,
newEndDate,
});
expect(result).toEqual([
{
id: "startTime",
value: {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: newStartDate,
endDate: newEndDate,
preset: "thisMonth", // Preserves original preset
},
},
},
mockTextFilter, // Non-date filters remain unchanged
]);
});
it("should replace createdAt filter with new dates", () => {
const columnFilters = [mockCreatedAtFilter, mockSingleSelectFilter];
const result = replaceDateRangeColumnFilter({
columnFilters,
newStartDate,
newEndDate,
});
expect(result).toEqual([
{
id: "createdAt",
value: {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: newStartDate,
endDate: newEndDate,
preset: "lastMonth", // Preserves original preset
},
},
},
mockSingleSelectFilter, // Non-date filters remain unchanged
]);
});
it("should preserve preset value when replacing filter", () => {
const customPresetFilter: ColumnFilter = {
id: "startTime",
value: {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-31T23:59:59.999Z",
preset: "customPreset",
},
},
};
const result = replaceDateRangeColumnFilter({
columnFilters: [customPresetFilter],
newStartDate,
newEndDate,
});
expect(result![0].value).toMatchObject({
data: expect.objectContaining({
preset: "customPreset",
}),
});
});
it("should leave non-date-range filters unchanged", () => {
const columnFilters = [mockTextFilter, mockSingleSelectFilter, mockStartTimeFilter];
const result = replaceDateRangeColumnFilter({
columnFilters,
newStartDate,
newEndDate,
});
expect(result).toEqual([
mockTextFilter, // Unchanged
mockSingleSelectFilter, // Unchanged
{
id: "startTime",
value: {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: newStartDate,
endDate: newEndDate,
preset: "thisMonth",
},
},
},
]);
});
it("should handle startTime filter replacement among other filters", () => {
const columnFilters = [mockTextFilter, mockStartTimeFilter, mockSingleSelectFilter];
const result = replaceDateRangeColumnFilter({
columnFilters,
newStartDate,
newEndDate,
});
expect(result).toEqual([
mockTextFilter, // Unchanged
{
id: "startTime",
value: {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: newStartDate,
endDate: newEndDate,
preset: "thisMonth",
},
},
},
mockSingleSelectFilter, // Unchanged
]);
});
it("should maintain filter structure when replacing", () => {
const result = replaceDateRangeColumnFilter({
columnFilters: [mockStartTimeFilter],
newStartDate,
newEndDate,
});
expect(result?.[0]).toHaveProperty("id");
expect(result?.[0]).toHaveProperty("value");
expect(result?.[0].value).toHaveProperty("type", ColumnFilterType.DATE_RANGE);
expect(result?.[0].value).toHaveProperty("data");
expect(result?.[0].value.data).toHaveProperty("startDate");
expect(result?.[0].value.data).toHaveProperty("endDate");
expect(result?.[0].value.data).toHaveProperty("preset");
});
});
describe("edge cases", () => {
it("should return undefined when no column filters provided", () => {
const result = replaceDateRangeColumnFilter({
columnFilters: undefined,
newStartDate,
newEndDate,
});
expect(result).toBeUndefined();
});
it("should handle empty column filters array", () => {
const result = replaceDateRangeColumnFilter({
columnFilters: [],
newStartDate,
newEndDate,
});
expect(result).toEqual([]);
});
it("should return unchanged filters when no date range filters exist", () => {
const columnFilters = [mockTextFilter, mockSingleSelectFilter];
const result = replaceDateRangeColumnFilter({
columnFilters,
newStartDate,
newEndDate,
});
expect(result).toEqual(columnFilters);
});
it("should not modify filters with startTime/createdAt id but wrong type", () => {
const invalidStartTimeFilter: ColumnFilter = {
id: "startTime",
value: {
type: ColumnFilterType.TEXT,
data: {
operator: "equals" as const,
operand: "some-date",
},
},
};
const result = replaceDateRangeColumnFilter({
columnFilters: [invalidStartTimeFilter],
newStartDate,
newEndDate,
});
expect(result).toEqual([invalidStartTimeFilter]); // Should remain unchanged
});
it("should handle filters with similar but different ids", () => {
const similarIdFilter: ColumnFilter = {
id: "startTimeRange", // Similar but not exact match
value: {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-31T23:59:59.999Z",
preset: "thisMonth",
},
},
};
const result = replaceDateRangeColumnFilter({
columnFilters: [similarIdFilter],
newStartDate,
newEndDate,
});
expect(result).toEqual([similarIdFilter]); // Should remain unchanged
});
it("should handle mixed case scenarios", () => {
const invalidStartTimeTextFilter: ColumnFilter = {
id: "startTime",
value: {
type: ColumnFilterType.TEXT, // Wrong type, should be ignored
data: { operator: "equals" as const, operand: "test" },
},
};
const mixedFilters = [
mockTextFilter,
invalidStartTimeTextFilter,
mockCreatedAtFilter, // Valid date range filter
mockSingleSelectFilter,
];
const result = replaceDateRangeColumnFilter({
columnFilters: mixedFilters,
newStartDate,
newEndDate,
});
expect(result).toEqual([
mockTextFilter, // Unchanged
invalidStartTimeTextFilter, // Unchanged (wrong type)
{
id: "createdAt",
value: {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: newStartDate,
endDate: newEndDate,
preset: "lastMonth", // Replaced
},
},
},
mockSingleSelectFilter, // Unchanged
]);
});
});
});
@@ -0,0 +1,57 @@
import { ColumnFilterType } from "@calcom/features/data-table/lib/types";
import { type ColumnFilter } from "@calcom/features/data-table/lib/types";
import { isDateRangeFilterValue } from "@calcom/features/data-table/lib/utils";
export function extractDateRangeFromColumnFilters(columnFilters?: ColumnFilter[]) {
if (!columnFilters) throw new Error("No date range filter found");
for (const filter of columnFilters) {
if ((filter.id === "startTime" || filter.id === "createdAt") && isDateRangeFilterValue(filter.value)) {
const dateFilter = filter.value as Extract<
ColumnFilter["value"],
{ type: ColumnFilterType.DATE_RANGE }
>;
if (dateFilter.data.startDate && dateFilter.data.endDate) {
return {
startDate: dateFilter.data.startDate,
endDate: dateFilter.data.endDate,
dateTarget: filter.id,
};
}
}
}
throw new Error("No date range filter found");
}
export function replaceDateRangeColumnFilter({
columnFilters,
newStartDate,
newEndDate,
}: {
columnFilters?: ColumnFilter[];
newStartDate: string;
newEndDate: string;
}) {
if (!columnFilters) {
return undefined;
}
return columnFilters.map((filter) => {
if ((filter.id === "startTime" || filter.id === "createdAt") && isDateRangeFilterValue(filter.value)) {
return {
id: filter.id,
value: {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: newStartDate,
endDate: newEndDate,
preset: filter.value.data.preset,
},
},
} satisfies ColumnFilter;
} else {
return filter;
}
});
}
@@ -103,8 +103,6 @@ export const routedToPerPeriodCsvInputSchema = routingRepositoryBaseInputSchema.
export const bookingRepositoryBaseInputSchema = z.object({
scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]),
selectedTeamId: z.number().optional(),
startDate: z.string(),
endDate: z.string(),
timeZone: z.string(),
columnFilters: z.array(ZColumnFilter).optional(),
});
@@ -1,6 +1,10 @@
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import {
extractDateRangeFromColumnFilters,
replaceDateRangeColumnFilter,
} from "@calcom/features/insights/lib/bookingUtils";
import {
insightsRoutingServiceInputSchema,
insightsRoutingServicePaginatedInputSchema,
@@ -313,10 +317,9 @@ export interface IResultTeamList {
*/
function createInsightsBookingService(
ctx: { user: { id: number; organizationId: number | null } },
input: z.infer<typeof bookingRepositoryBaseInputSchema>,
dateTarget: "createdAt" | "startTime" = "createdAt"
input: z.infer<typeof bookingRepositoryBaseInputSchema>
) {
const { scope, selectedTeamId, startDate, endDate, columnFilters } = input;
const { scope, selectedTeamId, columnFilters } = input;
return getInsightsBookingService({
options: {
scope,
@@ -326,11 +329,6 @@ function createInsightsBookingService(
},
filters: {
...(columnFilters && { columnFilters }),
dateRange: {
target: dateTarget,
startDate,
endDate,
},
},
});
}
@@ -365,10 +363,15 @@ export const insightsRouter = router({
// Calculate previous period dates and create service for previous period
const previousPeriodDates = currentPeriodService.calculatePreviousPeriodDates();
const previousPeriodColumnFilters = replaceDateRangeColumnFilter({
columnFilters: input.columnFilters,
newStartDate: previousPeriodDates.startDate,
newEndDate: previousPeriodDates.endDate,
});
const previousPeriodService = createInsightsBookingService(ctx, {
...input,
startDate: previousPeriodDates.startDate,
endDate: previousPeriodDates.endDate,
columnFilters: previousPeriodColumnFilters,
});
// Get previous period stats
@@ -456,7 +459,8 @@ export const insightsRouter = router({
};
}),
eventTrends: insightsPbacProcedure.input(bookingRepositoryBaseInputSchema).query(async ({ ctx, input }) => {
const { startDate, endDate, timeZone } = input;
const { columnFilters, timeZone } = input;
const { startDate, endDate } = extractDateRangeFromColumnFilters(columnFilters);
// Calculate timeView and dateRanges
const timeView = getTimeView(startDate, endDate);
@@ -492,7 +496,8 @@ export const insightsRouter = router({
averageEventDuration: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const { startDate, endDate, timeZone } = input;
const { columnFilters, timeZone } = input;
const { startDate, endDate, dateTarget } = extractDateRangeFromColumnFilters(columnFilters);
const insightsBookingService = createInsightsBookingService(ctx, input);
@@ -516,6 +521,7 @@ export const insightsRouter = router({
select: {
eventLength: true,
createdAt: true,
startTime: true,
},
});
@@ -527,7 +533,9 @@ export const insightsRouter = router({
}
for (const booking of allBookings) {
const periodStart = dayjs(booking.createdAt).startOf(startOfEndOf).format("ll");
const periodStart = dayjs(dateTarget === "startTime" ? booking.startTime : booking.createdAt)
.startOf(startOfEndOf)
.format("ll");
if (resultMap.has(periodStart)) {
const current = resultMap.get(periodStart);
if (!current) continue;
@@ -552,7 +560,10 @@ export const insightsRouter = router({
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
return await insightsBookingService.getMembersStatsWithCount("cancelled", "DESC");
return await insightsBookingService.getMembersStatsWithCount({
type: "cancelled",
sortOrder: "DESC",
});
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
@@ -560,10 +571,14 @@ export const insightsRouter = router({
membersWithMostCompletedBookings: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ input, ctx }) => {
const insightsBookingService = createInsightsBookingService(ctx, input, "startTime");
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
return await insightsBookingService.getMembersStatsWithCount("accepted", "DESC");
return await insightsBookingService.getMembersStatsWithCount({
type: "accepted",
sortOrder: "DESC",
completed: true,
});
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
@@ -571,10 +586,14 @@ export const insightsRouter = router({
membersWithLeastCompletedBookings: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ input, ctx }) => {
const insightsBookingService = createInsightsBookingService(ctx, input, "startTime");
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
return await insightsBookingService.getMembersStatsWithCount("accepted", "ASC");
return await insightsBookingService.getMembersStatsWithCount({
type: "accepted",
sortOrder: "ASC",
completed: true,
});
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
@@ -585,7 +604,7 @@ export const insightsRouter = router({
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
return await insightsBookingService.getMembersStatsWithCount("all", "DESC");
return await insightsBookingService.getMembersStatsWithCount({ type: "all", sortOrder: "DESC" });
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
@@ -596,7 +615,7 @@ export const insightsRouter = router({
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
return await insightsBookingService.getMembersStatsWithCount("all", "ASC");
return await insightsBookingService.getMembersStatsWithCount({ type: "all", sortOrder: "ASC" });
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
@@ -792,7 +811,7 @@ export const insightsRouter = router({
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
return await insightsBookingService.getMembersStatsWithCount("noShow", "DESC");
return await insightsBookingService.getMembersStatsWithCount({ type: "noShow", sortOrder: "DESC" });
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
@@ -1001,7 +1020,7 @@ export const insightsRouter = router({
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const { timeZone } = input;
const insightsBookingService = createInsightsBookingService(ctx, input, "startTime");
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
return await insightsBookingService.getBookingsByHourStats({
@@ -1014,7 +1033,7 @@ export const insightsRouter = router({
recentNoShowGuests: insightsPbacProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const insightsBookingService = createInsightsBookingService(ctx, input, "startTime");
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
return await insightsBookingService.getRecentNoShowGuests();
@@ -9,6 +9,7 @@ export enum Resource {
Role = "role",
RoutingForm = "routingForm",
Workflow = "workflow",
Webhook = "webhook",
}
export enum CrudAction {
@@ -516,4 +517,36 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
dependsOn: ["routingForm.read"],
},
},
[Resource.Webhook]: {
_resource: {
i18nKey: "pbac_resource_webhook",
},
[CrudAction.Create]: {
description: "Create webhooks",
category: "webhook",
i18nKey: "pbac_action_create",
descriptionI18nKey: "pbac_desc_create_webhooks",
dependsOn: ["webhook.read"],
},
[CrudAction.Read]: {
description: "View webhooks",
category: "webhook",
i18nKey: "pbac_action_read",
descriptionI18nKey: "pbac_desc_view_webhooks",
},
[CrudAction.Update]: {
description: "Update webhooks",
category: "webhook",
i18nKey: "pbac_action_update",
descriptionI18nKey: "pbac_desc_update_webhooks",
dependsOn: ["webhook.read"],
},
[CrudAction.Delete]: {
description: "Delete webhooks",
category: "webhook",
i18nKey: "pbac_action_delete",
descriptionI18nKey: "pbac_desc_delete_webhooks",
dependsOn: ["webhook.read"],
},
},
};
@@ -386,9 +386,6 @@ function UserListTableContent({
meta: {
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
endOfDay: true,
},
},
},
cell: ({ row }) => <div>{row.original.lastActiveAt}</div>,
@@ -402,9 +399,6 @@ function UserListTableContent({
meta: {
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
endOfDay: true,
},
},
},
cell: ({ row }) => <div>{row.original.createdAt || ""}</div>,
@@ -418,9 +412,6 @@ function UserListTableContent({
meta: {
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
endOfDay: true,
},
},
},
cell: ({ row }) => <div>{row.original.updatedAt || ""}</div>,
@@ -4,8 +4,9 @@ import { useRouter } from "next/navigation";
import { CreateButtonWithTeamsList } from "@calcom/features/ee/teams/components/createButton/CreateButtonWithTeamsList";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
export const CreateNewWebhookButton = ({ isAdmin }: { isAdmin: boolean }) => {
export const CreateNewWebhookButton = () => {
const router = useRouter();
const { t } = useLocale();
const createFunction = (teamId?: number, platform?: boolean) => {
@@ -20,10 +21,13 @@ export const CreateNewWebhookButton = ({ isAdmin }: { isAdmin: boolean }) => {
<CreateButtonWithTeamsList
color="secondary"
subtitle={t("create_for").toUpperCase()}
isAdmin={isAdmin}
createFunction={createFunction}
data-testid="new_webhook"
includeOrg={true}
withPermission={{
permission: "webhook.create",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
}}
/>
);
};
@@ -36,12 +36,14 @@ export default function WebhookListItem(props: {
canEditWebhook?: boolean;
onEditWebhook: () => void;
lastItem: boolean;
readOnly?: boolean;
permissions: {
canEditWebhook?: boolean;
canDeleteWebhook?: boolean;
};
}) {
const { t } = useLocale();
const utils = trpc.useUtils();
const { webhook } = props;
const canEditWebhook = props.canEditWebhook ?? true;
const deleteWebhook = trpc.viewer.webhook.delete.useMutation({
async onSuccess() {
@@ -87,7 +89,7 @@ export default function WebhookListItem(props: {
{webhook.subscriberUrl}
</p>
</Tooltip>
{!!props.readOnly && (
{!props.permissions.canEditWebhook && (
<Badge variant="gray" className="ml-2 ">
{t("readonly")}
</Badge>
@@ -107,12 +109,12 @@ export default function WebhookListItem(props: {
</div>
</Tooltip>
</div>
{!props.readOnly && (
{(props.permissions.canEditWebhook || props.permissions.canDeleteWebhook) && (
<div className="ml-2 flex items-center space-x-4">
<Switch
defaultChecked={webhook.active}
data-testid="webhook-switch"
disabled={!canEditWebhook}
disabled={!props.permissions.canEditWebhook}
onCheckedChange={() =>
toggleWebhook.mutate({
id: webhook.id,
@@ -123,39 +125,48 @@ export default function WebhookListItem(props: {
}
/>
<Button
className="hidden lg:flex"
color="secondary"
onClick={props.onEditWebhook}
data-testid="webhook-edit-button">
{t("edit")}
</Button>
{props.permissions.canEditWebhook && (
<Button
className="hidden lg:flex"
color="secondary"
onClick={props.onEditWebhook}
data-testid="webhook-edit-button">
{t("edit")}
</Button>
)}
<Button
className="hidden lg:flex"
color="destructive"
StartIcon="trash"
variant="icon"
onClick={onDeleteWebhook}
/>
{props.permissions.canDeleteWebhook && (
<Button
className="hidden lg:flex"
color="destructive"
StartIcon="trash"
variant="icon"
onClick={onDeleteWebhook}
/>
)}
<Dropdown>
<DropdownMenuTrigger asChild>
<Button className="lg:hidden" StartIcon="ellipsis" variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem StartIcon="pencil" color="secondary" onClick={props.onEditWebhook}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
{props.permissions.canEditWebhook && (
<DropdownMenuItem>
<DropdownItem StartIcon="pencil" color="secondary" onClick={props.onEditWebhook}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownItem StartIcon="trash" color="destructive" onClick={onDeleteWebhook}>
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
{props.permissions.canDeleteWebhook && (
<DropdownMenuItem>
<DropdownItem StartIcon="trash" color="destructive" onClick={onDeleteWebhook}>
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
</div>
@@ -7,33 +7,27 @@ import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import type { WebhooksByViewer } from "@calcom/trpc/server/routers/viewer/webhook/getByViewer.handler";
import classNames from "@calcom/ui/classNames";
import { Avatar } from "@calcom/ui/components/avatar";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { WebhookListItem, CreateNewWebhookButton } from "../components";
type WebhooksByViewer = RouterOutputs["viewer"]["webhook"]["getByViewer"];
type Props = {
data: RouterOutputs["viewer"]["webhook"]["getByViewer"];
isAdmin: boolean;
data: WebhooksByViewer;
};
const WebhooksView = ({ data, isAdmin }: Props) => {
const WebhooksView = ({ data }: Props) => {
return (
<div>
<WebhooksList webhooksByViewer={data} isAdmin={isAdmin} />
<WebhooksList webhooksByViewer={data} />
</div>
);
};
const WebhooksList = ({
webhooksByViewer,
isAdmin,
}: {
webhooksByViewer: WebhooksByViewer;
isAdmin: boolean;
}) => {
const WebhooksList = ({ webhooksByViewer }: { webhooksByViewer: WebhooksByViewer }) => {
const { t } = useLocale();
const router = useRouter();
const { profiles, webhookGroups } = webhooksByViewer;
@@ -45,7 +39,7 @@ const WebhooksList = ({
<SettingsHeader
title={t("webhooks")}
description={t("add_webhook_description", { appName: APP_NAME })}
CTA={webhooksByViewer.webhookGroups.length > 0 ? <CreateNewWebhookButton isAdmin={isAdmin} /> : null}
CTA={webhooksByViewer.webhookGroups.length > 0 ? <CreateNewWebhookButton /> : null}
borderInShellHeader={false}>
{!!webhookGroups.length ? (
<div className={classNames("mt-6")}>
@@ -70,8 +64,11 @@ const WebhooksList = ({
<WebhookListItem
key={webhook.id}
webhook={webhook}
readOnly={group.metadata?.readOnly ?? false}
lastItem={group.webhooks.length === index + 1}
permissions={{
canEditWebhook: group?.metadata?.canModify ?? false,
canDeleteWebhook: group?.metadata?.canDelete ?? false,
}}
onEditWebhook={() =>
router.push(`${WEBAPP_URL}/settings/developer/webhooks/${webhook.id}`)
}
@@ -88,7 +85,7 @@ const WebhooksList = ({
headline={t("create_your_first_webhook")}
description={t("create_your_first_webhook_description", { appName: APP_NAME })}
className="mt-6 rounded-b-lg"
buttonRaw={<CreateNewWebhookButton isAdmin={isAdmin} />}
buttonRaw={<CreateNewWebhookButton />}
border={true}
/>
)}
@@ -39,23 +39,4 @@ describe("defaultHandler Test Suite", () => {
expect(getHandler).toHaveBeenCalledWith(req, res);
});
it("should return 500 for errors thrown in handler", async () => {
const getHandler = vi.fn().mockRejectedValue(new Error("Test Error"));
const handlers = {
GET: { default: getHandler },
};
const handler = defaultHandler(handlers);
const { req, res } = createMocks({
method: "GET",
});
await handler(req, res);
expect(res._getStatusCode()).toBe(500);
expect(res._getJSONData()).toEqual({
message: "Something went wrong",
});
});
});
+1 -8
View File
@@ -13,12 +13,5 @@ export const defaultHandler = (handlers: Handlers) => async (req: NextApiRequest
.status(405)
.json({ message: `Method Not Allowed (Allow: ${Object.keys(handlers).join(",")})` });
}
try {
await handler(req, res);
return;
} catch (error) {
console.error(error);
return res.status(500).json({ message: "Something went wrong" });
}
return await handler(req, res);
};
+143 -132
View File
@@ -1,5 +1,5 @@
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { compareMembership } from "@calcom/lib/event-types/getEventTypesByViewer";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { prisma } from "@calcom/prisma";
import type { Webhook } from "@calcom/prisma/client";
@@ -14,7 +14,8 @@ type WebhookGroup = {
image?: string;
};
metadata?: {
readOnly: boolean;
canModify: boolean;
canDelete: boolean;
};
webhooks: Webhook[];
};
@@ -30,136 +31,6 @@ const filterWebhooks = (webhook: Webhook) => {
};
export class WebhookRepository {
static async getAllWebhooksByUserId({
userId,
userRole,
}: {
userId: number;
userRole?: UserPermissionRole;
}) {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
username: true,
avatarUrl: true,
name: true,
webhooks: true,
teams: {
where: {
accepted: true,
},
select: {
role: true,
team: {
select: {
id: true,
isOrganization: true,
name: true,
slug: true,
parentId: true,
metadata: true,
members: {
select: {
userId: true,
},
},
webhooks: true,
logoUrl: true,
},
},
},
},
},
});
if (!user) {
throw new Error("User not found");
}
let userWebhooks = user.webhooks;
userWebhooks = userWebhooks.filter(filterWebhooks);
let webhookGroups: WebhookGroup[] = [];
webhookGroups.push({
teamId: null,
profile: {
slug: user.username,
name: user.name,
image: getUserAvatarUrl({
avatarUrl: user.avatarUrl,
}),
},
webhooks: userWebhooks,
metadata: {
readOnly: false,
},
});
const teamMemberships = user.teams.map((membership) => ({
teamId: membership.team.id,
membershipRole: membership.role,
}));
const teamWebhookGroups: WebhookGroup[] = user.teams.map((membership) => {
const orgMembership = teamMemberships.find(
(teamM) => teamM.teamId === membership.team.parentId
)?.membershipRole;
return {
teamId: membership.team.id,
profile: {
name: membership.team.name,
slug: membership.team.slug
? !membership.team.parentId
? `/team`
: `${membership.team.slug}`
: null,
image: getPlaceholderAvatar(membership.team.logoUrl, membership.team.name),
},
metadata: {
readOnly:
membership.role ===
(membership.team.parentId
? orgMembership && compareMembership(orgMembership, membership.role)
? orgMembership
: MembershipRole.MEMBER
: MembershipRole.MEMBER),
},
webhooks: membership.team.webhooks.filter(filterWebhooks),
};
});
webhookGroups = webhookGroups.concat(teamWebhookGroups);
if (userRole === "ADMIN") {
const platformWebhooks = await prisma.webhook.findMany({
where: { platform: true },
});
webhookGroups.push({
teamId: null,
profile: {
slug: "Platform",
name: "Platform",
image: getPlaceholderAvatar(null, "Platform"),
},
webhooks: platformWebhooks,
metadata: {
readOnly: false,
},
});
}
return {
webhookGroups: webhookGroups.filter((groupBy) => !!groupBy.webhooks?.length),
profiles: webhookGroups.map((group) => ({
teamId: group.teamId,
...group.profile,
...group.metadata,
})),
};
}
static async findByWebhookId(webhookId?: string) {
return await prisma.webhook.findUniqueOrThrow({
where: {
@@ -180,4 +51,144 @@ export class WebhookRepository {
},
});
}
static async getFilteredWebhooksForUser({
userId,
userRole,
}: {
userId: number;
userRole?: UserPermissionRole;
}) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
username: true,
name: true,
avatarUrl: true,
webhooks: true,
teams: {
where: {
accepted: true,
},
select: {
role: true,
team: {
select: {
id: true,
name: true,
slug: true,
logoUrl: true,
webhooks: true,
},
},
},
},
},
});
if (!user) {
throw new Error("User not found");
}
// Use permission service which handles both PBAC and role-based fallbacks
const permissionService = new PermissionCheckService();
// Build webhook groups with proper permissions
const webhookGroups: WebhookGroup[] = [];
// Add user's personal webhooks
webhookGroups.push({
teamId: null,
profile: {
slug: user.username,
name: user.name,
image: getUserAvatarUrl({ avatarUrl: user.avatarUrl }),
},
webhooks: user.webhooks.filter(filterWebhooks),
metadata: {
canModify: true,
canDelete: true,
},
});
// Check permissions for each team
// The permission service handles PBAC when enabled and falls back to role-based permissions
for (const membership of user.teams) {
const teamId = membership.team.id;
// Check read permission (fallback: MEMBER, ADMIN, OWNER can read)
const canRead = await permissionService.checkPermission({
userId,
teamId,
permission: "webhook.read",
fallbackRoles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
});
if (!canRead) {
// User doesn't have permission to view this team's webhooks
continue;
}
// Check update/delete permissions in parallel (fallback: only ADMIN, OWNER can modify)
const [canUpdate, canDelete] = await Promise.all([
permissionService.checkPermission({
userId,
teamId,
permission: "webhook.update",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
}),
permissionService.checkPermission({
userId,
teamId,
permission: "webhook.delete",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
}),
]);
webhookGroups.push({
teamId: membership.team.id,
profile: {
name: membership.team.name,
slug: membership.team.slug || null,
image: getPlaceholderAvatar(membership.team.logoUrl, membership.team.name),
},
webhooks: membership.team.webhooks.filter(filterWebhooks),
metadata: {
canModify: canUpdate,
canDelete,
},
});
}
// Add platform webhooks for admins
if (userRole === "ADMIN") {
const platformWebhooks = await prisma.webhook.findMany({
where: { platform: true },
});
webhookGroups.push({
teamId: null,
profile: {
slug: "Platform",
name: "Platform",
image: getPlaceholderAvatar(null, "Platform"),
},
webhooks: platformWebhooks,
metadata: {
canDelete: true,
canModify: true,
},
});
}
return {
webhookGroups: webhookGroups.filter((group) => group.webhooks.length > 0),
profiles: webhookGroups.map((group) => ({
teamId: group.teamId,
...group.profile,
...group.metadata,
})),
};
}
}
@@ -4,13 +4,19 @@ import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { makeSqlCondition } from "@calcom/features/data-table/lib/server";
import { ZColumnFilter } from "@calcom/features/data-table/lib/types";
import type { ColumnFilter } from "@calcom/features/data-table/lib/types";
import { ColumnFilterType } from "@calcom/features/data-table/lib/types";
import { type ColumnFilter } from "@calcom/features/data-table/lib/types";
import {
isSingleSelectFilterValue,
isMultiSelectFilterValue,
isTextFilterValue,
isNumberFilterValue,
isDateRangeFilterValue,
} from "@calcom/features/data-table/lib/utils";
import {
extractDateRangeFromColumnFilters,
replaceDateRangeColumnFilter,
} from "@calcom/features/insights/lib/bookingUtils";
import type { DateRange } from "@calcom/features/insights/server/insightsDateUtils";
import type { PrismaClient } from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
@@ -127,13 +133,6 @@ export type InsightsBookingServiceOptions = z.infer<typeof insightsBookingServic
export type InsightsBookingServiceFilterOptions = z.infer<typeof insightsBookingServiceFilterOptionsSchema>;
export const insightsBookingServiceFilterOptionsSchema = z.object({
dateRange: z
.object({
target: z.enum(["createdAt", "startTime"]),
startDate: z.string(),
endDate: z.string(),
})
.optional(),
columnFilters: z.array(ZColumnFilter).optional(),
});
@@ -273,23 +272,6 @@ export class InsightsBookingBaseService {
}
}
// Use dateRange object for date filtering
if (this.filters.dateRange) {
const { target, startDate, endDate } = this.filters.dateRange;
if (startDate) {
if (isNaN(Date.parse(startDate))) {
throw new Error(`Invalid date format: ${startDate}`);
}
conditions.push(Prisma.sql`"${Prisma.raw(target)}" >= ${startDate}::timestamp`);
}
if (endDate) {
if (isNaN(Date.parse(endDate))) {
throw new Error(`Invalid date format: ${endDate}`);
}
conditions.push(Prisma.sql`"${Prisma.raw(target)}" <= ${endDate}::timestamp`);
}
}
if (conditions.length === 0) {
return null;
}
@@ -355,6 +337,39 @@ export class InsightsBookingBaseService {
}
}
if ((id === "startTime" || id === "createdAt") && isDateRangeFilterValue(value)) {
const conditions: Prisma.Sql[] = [];
// if `startTime` filter -> x <= "startTime" AND "endTime" <= y
// if `createdAt` filter -> x <= "createdAt" AND "createdAt" <= y
if (value.data.startDate) {
if (isNaN(Date.parse(value.data.startDate))) {
throw new Error(`Invalid date format: ${value.data.startDate}`);
}
if (id === "startTime") {
conditions.push(Prisma.sql`${value.data.startDate}::timestamp <= "startTime"`);
} else {
conditions.push(Prisma.sql`${value.data.startDate}::timestamp <= "createdAt"`);
}
}
if (value.data.endDate) {
if (isNaN(Date.parse(value.data.endDate))) {
throw new Error(`Invalid date format: ${value.data.endDate}`);
}
if (id === "startTime") {
conditions.push(Prisma.sql`"endTime" <= ${value.data.endDate}::timestamp`);
} else {
conditions.push(Prisma.sql`"createdAt" <= ${value.data.endDate}::timestamp`);
}
}
if (conditions.length === 0) {
return null;
}
return conditions.reduce((acc, condition, index) => {
if (index === 0) return condition;
return Prisma.sql`(${acc}) AND (${condition})`;
});
}
return null;
}
@@ -863,27 +878,42 @@ export class InsightsBookingBaseService {
return result;
}
async getMembersStatsWithCount(
type: "all" | "accepted" | "cancelled" | "noShow" = "all",
sortOrder: "ASC" | "DESC" = "DESC"
): Promise<UserStatsData> {
async getMembersStatsWithCount({
type = "all",
sortOrder = "DESC",
completed,
}: {
type?: "all" | "accepted" | "cancelled" | "noShow";
sortOrder?: "ASC" | "DESC";
completed?: boolean;
} = {}): Promise<UserStatsData> {
const baseConditions = await this.getBaseConditions();
let additionalCondition = Prisma.sql``;
const conditions: Prisma.Sql[] = [Prisma.sql`"userId" IS NOT NULL`];
if (type === "cancelled") {
additionalCondition = Prisma.sql`AND status = 'cancelled'`;
conditions.push(Prisma.sql`status = 'cancelled'`);
} else if (type === "noShow") {
additionalCondition = Prisma.sql`AND "noShowHost" = true`;
conditions.push(Prisma.sql`"noShowHost" = true`);
} else if (type === "accepted") {
additionalCondition = Prisma.sql`AND status = 'accepted'`;
conditions.push(Prisma.sql`status = 'accepted'`);
}
if (completed) {
conditions.push(Prisma.sql`"endTime" <= NOW()`);
}
const additionalCondition = conditions.reduce((acc, condition, index) => {
if (index === 0) return condition;
return Prisma.sql`(${acc}) AND (${condition})`;
});
const query = Prisma.sql`
SELECT
"userId",
COUNT(id)::int as count
FROM "BookingTimeStatusDenormalized"
WHERE ${baseConditions} AND "userId" IS NOT NULL ${additionalCondition}
WHERE (${baseConditions}) AND (${additionalCondition})
GROUP BY "userId"
ORDER BY count ${sortOrder === "ASC" ? Prisma.sql`ASC` : Prisma.sql`DESC`}
LIMIT 10
@@ -1208,12 +1238,10 @@ export class InsightsBookingBaseService {
}
calculatePreviousPeriodDates() {
if (!this.filters?.dateRange) {
throw new Error("Date range is required for calculating previous period");
}
const result = extractDateRangeFromColumnFilters(this.filters?.columnFilters);
const startDate = dayjs(result.startDate);
const endDate = dayjs(result.endDate);
const startDate = dayjs(this.filters.dateRange.startDate);
const endDate = dayjs(this.filters.dateRange.endDate);
const startTimeEndTimeDiff = endDate.diff(startDate, "day");
const lastPeriodStartDate = startDate.subtract(startTimeEndTimeDiff, "day");
@@ -2,8 +2,8 @@ import prismock from "../../../../tests/libs/__mocks__/prisma";
import { describe, test, expect, vi, beforeEach } from "vitest";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import { CreationSource } from "@calcom/prisma/enums";
import { UserRepository } from "../repository/user";
@@ -20,7 +20,7 @@ vi.mock("@calcom/lib/server/i18n", () => {
};
});
vi.mock("@calcom/features/auth/lib/hashPassword", () => ({
vi.mock("@calcom/lib/auth/hashPassword", () => ({
hashPassword: vi.fn().mockResolvedValue("hashed-password"),
}));
@@ -1,5 +1,5 @@
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import type { CreationSource, UserPermissionRole, IdentityProvider } from "@calcom/prisma/enums";
@@ -139,21 +139,33 @@ const BookerPlatformWrapperComponent = (props: BookerWebWrapperAtomProps) => {
const isEmbed = useIsEmbed();
const _month = dayjs(date).month();
const _monthAfterAdding1Month = dayjs(date).add(1, "month").month();
const _monthAfterAddingExtraDays = dayjs(date).add(bookerLayout.extraDays, "day").month();
const _monthAfterAddingExtraDaysColumnView = dayjs(date)
.add(bookerLayout.columnViewExtraDays.current, "day")
.month();
const _isValidDate = dayjs(date).isValid();
const _2WeeksAfter = dayjs(month).startOf("month").add(2, "week");
const _isSameMonth = dayjs().isSame(dayjs(month), "month");
const _isAfter2Weeks = dayjs().isAfter(_2WeeksAfter);
const prefetchNextMonth =
(bookerLayout.layout === BookerLayouts.WEEK_VIEW &&
!!bookerLayout.extraDays &&
dayjs(date).month() !== dayjs(date).add(bookerLayout.extraDays, "day").month()) ||
(bookerLayout.layout === BookerLayouts.COLUMN_VIEW &&
dayjs(date).month() !== dayjs(date).add(bookerLayout.columnViewExtraDays.current, "day").month()) ||
_month !== _monthAfterAddingExtraDays) ||
(bookerLayout.layout === BookerLayouts.COLUMN_VIEW && _month !== _monthAfterAddingExtraDaysColumnView) ||
((bookerLayout.layout === BookerLayouts.MONTH_VIEW || bookerLayout.layout === "mobile") &&
(!dayjs(date).isValid() || dayjs().isSame(dayjs(month), "month")) &&
dayjs().isAfter(dayjs(month).startOf("month").add(2, "week")));
(!_isValidDate || _isSameMonth) &&
_isAfter2Weeks);
const monthCount =
((bookerLayout.layout !== BookerLayouts.WEEK_VIEW && bookerState === "selecting_time") ||
bookerLayout.layout === BookerLayouts.COLUMN_VIEW) &&
dayjs(date).add(1, "month").month() !==
dayjs(date).add(bookerLayout.columnViewExtraDays.current, "day").month()
!isNaN(_monthAfterAdding1Month) &&
!isNaN(_monthAfterAddingExtraDaysColumnView) &&
_monthAfterAdding1Month !== _monthAfterAddingExtraDaysColumnView
? 2
: undefined;
/**
@@ -2,7 +2,8 @@ import { test, expect } from "@playwright/test";
import { generateRandomText } from "../../src/lib/generateRandomText";
test("create team event using CreateTeamEventTypeAtom", async ({ page }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip("create team event using CreateTeamEventTypeAtom", async ({ page }) => {
await page.goto("/");
await page.goto("/event-types");
@@ -0,0 +1,22 @@
-- Add webhook permissions for admin role
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
SELECT
gen_random_uuid(), 'admin_role', resource, action, NOW()
FROM (
VALUES
-- Attribute permissions
('webhook', 'create'),
('webhook', 'read'),
('webhook', 'update'),
('webhook', 'delete')
) AS permissions(resource, action);
-- Add read permission for member role
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
SELECT
gen_random_uuid(), 'member_role', resource, action, NOW()
FROM (
VALUES
-- Attribute permissions - read only
('webhook', 'read')
) AS permissions(resource, action);
+2 -3
View File
@@ -18,11 +18,10 @@
"post-install": "yarn generate-schemas",
"seed-app-store": "ts-node --transpile-only ../../scripts/seed-app-store.ts",
"delete-app": "ts-node --transpile-only ./delete-app.ts",
"seed-insights": "ts-node --transpile-only ./seed-insights.ts",
"seed-pbac": "ts-node --transpile-only ./seed-pbac-organization.ts"
"seed-insights": "ts-node --transpile-only ../../scripts/seed-insights.ts",
"seed-pbac": "ts-node --transpile-only ../../scripts/seed-pbac-organization.ts"
},
"devDependencies": {
"@faker-js/faker": "9.2.0",
"npm-run-all": "^4.1.5"
},
"dependencies": {
+192 -7
View File
@@ -11,10 +11,9 @@ import type {
ZodTypeAny,
} from "zod";
import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid";
import type { FieldType as FormBuilderFieldType } from "@calcom/features/form-builder/schema";
import { fieldsSchema as formBuilderFieldsSchema } from "@calcom/features/form-builder/schema";
import { isPasswordValid } from "@calcom/lib/auth/isPasswordValid";
import { emailSchema as emailRegexSchema, emailRegex } from "@calcom/lib/emailSchema";
import { getValidRhfFieldName } from "@calcom/lib/getValidRhfFieldName";
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
import { zodAttributesQueryValue } from "@calcom/lib/raqb/zod";
import { slugify } from "@calcom/lib/slugify";
@@ -135,10 +134,6 @@ export const eventTypeMetaDataSchemaWithoutApps = _eventTypeMetaDataSchemaWithou
export type EventTypeMetadata = z.infer<typeof EventTypeMetaDataSchema>;
export const eventTypeBookingFields = formBuilderFieldsSchema;
export const BookingFieldTypeEnum = eventTypeBookingFields.element.shape.type.Enum;
export type BookingFieldType = FormBuilderFieldType;
// Validation of user added bookingFields' responses happen using `getBookingResponsesSchema` which requires `eventType`.
// So it is a dynamic validation and thus entire validation can't exist here
// Note that this validation runs to validate prefill params as well, so it should consider that partial values can be there. e.g. `name` might be empty string
@@ -768,3 +763,193 @@ export const serviceAccountKeySchema = z
export type TServiceAccountKeySchema = z.infer<typeof serviceAccountKeySchema>;
export const rrSegmentQueryValueSchema = zodAttributesQueryValue.nullish();
// Routing Form Fields
export const fieldTypeEnum = z.enum([
"name",
"text",
"textarea",
"number",
"email",
"phone",
"address",
"multiemail",
"select",
"multiselect",
"checkbox",
"radio",
"radioInput",
"boolean",
"url",
]);
export type FieldType = z.infer<typeof fieldTypeEnum>;
export const excludeOrRequireEmailSchema = z.string().superRefine((val, ctx) => {
const allDomains = val.split(",").map((dom) => dom.trim());
const regex = /^(?:@?[a-z0-9-]+(?:\.[a-z]{2,})?)?(?:@[a-z0-9-]+\.[a-z]{2,})?$/;
/*
Valid patterns - [ example, example.anything, anyone@example.anything ]
Invalid patterns - Patterns involving capital letter [ Example, Example.anything, Anyone@example.anything ]
*/
const isValid = !allDomains.some((domain) => !regex.test(domain));
if (!isValid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Enter valid domain or email",
});
}
});
export const EditableSchema = z.enum([
"system", // Can't be deleted, can't be hidden, name can't be edited, can't be marked optional
"system-but-optional", // Can't be deleted. Name can't be edited. But can be hidden or be marked optional
"system-but-hidden", // Can't be deleted, name can't be edited, will be shown
"user", // Fully editable
"user-readonly", // All fields are readOnly.
]);
export const baseFieldSchema = z.object({
name: z.string().transform(getValidRhfFieldName),
type: fieldTypeEnum,
// TODO: We should make at least one of `defaultPlaceholder` and `placeholder` required. Do the same for label.
label: z.string().optional(),
labelAsSafeHtml: z.string().optional(),
/**
* It is the default label that will be used when a new field is created.
* Note: It belongs in FieldsTypeConfig, so that changing defaultLabel in code can work for existing fields as well(for fields that are using the default label).
* Supports translation
*/
defaultLabel: z.string().optional(),
placeholder: z.string().optional(),
/**
* It is the default placeholder that will be used when a new field is created.
* Note: Same as defaultLabel, it belongs in FieldsTypeConfig
* Supports translation
*/
defaultPlaceholder: z.string().optional(),
required: z.boolean().default(false).optional(),
/**
* It is the list of options that is valid for a certain type of fields.
*
*/
options: z
.array(
z.object({
label: z.string(),
value: z.string(),
price: z.coerce.number().min(0).optional(),
})
)
.optional(),
/**
* This is an alternate way to specify options when the options are stored elsewhere. Form Builder expects options to be present at `dataStore[getOptionsAt]`
* This allows keeping a single source of truth in DB.
*/
getOptionsAt: z.string().optional(),
/**
* For `radioInput` type of questions, it stores the input that is shown based on the user option selected.
* e.g. If user is given a list of locations and he selects "Phone", then he will be shown a phone input
*/
optionsInputs: z
.record(
z.object({
// Support all types as needed
// Must be a subset of `fieldTypeEnum`.TODO: Enforce it in TypeScript
type: z.enum(["address", "phone", "text"]),
required: z.boolean().optional(),
placeholder: z.string().optional(),
})
)
.optional(),
/**
* It is the minimum number of characters that can be entered in the field.
* It is used for types with `supportsLengthCheck= true`.
* @default 0
* @requires supportsLengthCheck = true
*/
minLength: z.number().optional(),
/**
* It is the maximum number of characters that can be entered in the field.
* It is used for types with `supportsLengthCheck= true`.
* @requires supportsLengthCheck = true
*/
maxLength: z.number().optional(),
// Emails that needs to be excluded
excludeEmails: excludeOrRequireEmailSchema.optional(),
// Emails that need to be required
requireEmails: excludeOrRequireEmailSchema.optional(),
// Price associated with the field which works like addons which users can add to the booking
price: z.coerce.number().min(0).optional(),
});
export const variantsConfigSchema = z.object({
variants: z.record(
z.object({
/**
* Variant Fields schema for a variant of the main field.
* It doesn't support non text fields as of now
**/
fields: baseFieldSchema
.omit({
defaultLabel: true,
defaultPlaceholder: true,
options: true,
getOptionsAt: true,
optionsInputs: true,
})
.array(),
})
),
});
export const fieldSchema = baseFieldSchema.merge(
z.object({
variant: z.string().optional(),
variantsConfig: variantsConfigSchema.optional(),
views: z
.object({
label: z.string(),
id: z.string(),
description: z.string().optional(),
})
.array()
.optional(),
/**
* It is used to hide fields such as location when there are less than two options
*/
hideWhenJustOneOption: z.boolean().default(false).optional(),
hidden: z.boolean().optional(),
editable: EditableSchema.default("user").optional(),
sources: z
.array(
z.object({
// Unique ID for the `type`. If type is workflow, it's the workflow ID
id: z.string(),
type: z.union([z.literal("user"), z.literal("system"), z.string()]),
label: z.string(),
editUrl: z.string().optional(),
// Mark if a field is required by this source or not. This allows us to set `field.required` based on all the sources' fieldRequired value
fieldRequired: z.boolean().optional(),
})
)
.optional(),
disableOnPrefill: z.boolean().default(false).optional(),
})
);
export const eventTypeBookingFields = z.array(fieldSchema);
export const BookingFieldTypeEnum = eventTypeBookingFields.element.shape.type.Enum;
@@ -112,7 +112,6 @@ export const teamsAndUserProfilesQuery = async ({ ctx, input }: TeamsAndUserProf
// Store permission results for teams that passed the filter
hasPermissionForFiltered = permissionChecks.filter((hasPermission) => hasPermission);
teamsData = teamsData.filter((_, index) => permissionChecks[index]);
}
return [
@@ -1,6 +1,6 @@
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { validPassword } from "@calcom/features/auth/lib/validPassword";
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import { prisma } from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
@@ -1,7 +1,7 @@
import { createHash } from "crypto";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import { prisma } from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
@@ -5,7 +5,7 @@ import { ZEditInputSchema } from "./edit.schema";
import { ZGetInputSchema } from "./get.schema";
import { ZListInputSchema } from "./list.schema";
import { ZTestTriggerInputSchema } from "./testTrigger.schema";
import { webhookProcedure } from "./util";
import { createWebhookPbacProcedure } from "./util";
type WebhookRouterHandlerCache = {
list?: typeof import("./list.handler").listHandler;
@@ -20,118 +20,132 @@ type WebhookRouterHandlerCache = {
const UNSTABLE_HANDLER_CACHE: WebhookRouterHandlerCache = {};
export const webhookRouter = router({
list: webhookProcedure.input(ZListInputSchema).query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.list) {
UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler);
list: createWebhookPbacProcedure("webhook.read")
.input(ZListInputSchema)
.query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.list) {
UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.list) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.list({
ctx,
input,
});
}),
get: createWebhookPbacProcedure("webhook.read", ["ADMIN", "OWNER", "MEMBER"])
.input(ZGetInputSchema)
.query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.get) {
UNSTABLE_HANDLER_CACHE.get = await import("./get.handler").then((mod) => mod.getHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.get) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.get({
ctx,
input,
});
}),
create: createWebhookPbacProcedure("webhook.create")
.input(ZCreateInputSchema)
.mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.create) {
UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.create) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.create({
ctx,
input,
});
}),
edit: createWebhookPbacProcedure("webhook.update")
.input(ZEditInputSchema)
.mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.edit) {
UNSTABLE_HANDLER_CACHE.edit = await import("./edit.handler").then((mod) => mod.editHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.edit) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.edit({
ctx,
input,
});
}),
delete: createWebhookPbacProcedure("webhook.delete")
.input(ZDeleteInputSchema)
.mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.delete) {
UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.delete) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.delete({
ctx,
input,
});
}),
testTrigger: createWebhookPbacProcedure("webhook.update")
.input(ZTestTriggerInputSchema)
.mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.testTrigger) {
UNSTABLE_HANDLER_CACHE.testTrigger = await import("./testTrigger.handler").then(
(mod) => mod.testTriggerHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.testTrigger) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.testTrigger({
ctx,
input,
});
}),
getByViewer: createWebhookPbacProcedure("webhook.read", ["ADMIN", "OWNER", "MEMBER"]).query(
async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.getByViewer) {
UNSTABLE_HANDLER_CACHE.getByViewer = await import("./getByViewer.handler").then(
(mod) => mod.getByViewerHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getByViewer) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.getByViewer({
ctx,
});
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.list) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.list({
ctx,
input,
});
}),
get: webhookProcedure.input(ZGetInputSchema).query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.get) {
UNSTABLE_HANDLER_CACHE.get = await import("./get.handler").then((mod) => mod.getHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.get) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.get({
ctx,
input,
});
}),
create: webhookProcedure.input(ZCreateInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.create) {
UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.create) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.create({
ctx,
input,
});
}),
edit: webhookProcedure.input(ZEditInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.edit) {
UNSTABLE_HANDLER_CACHE.edit = await import("./edit.handler").then((mod) => mod.editHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.edit) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.edit({
ctx,
input,
});
}),
delete: webhookProcedure.input(ZDeleteInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.delete) {
UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.delete) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.delete({
ctx,
input,
});
}),
testTrigger: webhookProcedure.input(ZTestTriggerInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.testTrigger) {
UNSTABLE_HANDLER_CACHE.testTrigger = await import("./testTrigger.handler").then(
(mod) => mod.testTriggerHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.testTrigger) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.testTrigger({
ctx,
input,
});
}),
getByViewer: webhookProcedure.query(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.getByViewer) {
UNSTABLE_HANDLER_CACHE.getByViewer = await import("./getByViewer.handler").then(
(mod) => mod.getByViewerHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getByViewer) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.getByViewer({
ctx,
});
}),
),
});
@@ -1,9 +1,11 @@
import { v4 } from "uuid";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { updateTriggerForExistingBookings } from "@calcom/features/webhooks/lib/scheduleTrigger";
import { prisma } from "@calcom/prisma";
import type { Webhook } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
@@ -29,6 +31,23 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (input.teamId) {
const permissionService = new PermissionCheckService();
const hasPermission = await permissionService.checkPermission({
userId: user.id,
teamId: input.teamId,
permission: "webhook.create",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
if (!hasPermission) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
}
// Add userId if platform, eventTypeId, and teamId are not provided
if (!input.platform && !input.eventTypeId && !input.teamId) {
webhookData.user = { connect: { id: user.id } };
@@ -1,8 +1,12 @@
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { updateTriggerForExistingBookings } from "@calcom/features/webhooks/lib/scheduleTrigger";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import type { TDeleteInputSchema } from "./delete.schema";
type DeleteOptions = {
@@ -21,6 +25,21 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
if (input.eventTypeId) {
where.AND.push({ eventTypeId: input.eventTypeId });
} else if (input.teamId) {
const permissionService = new PermissionCheckService();
const hasPermission = await permissionService.checkPermission({
userId: ctx.user.id,
teamId: input.teamId,
permission: "webhook.delete",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
if (!hasPermission) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
where.AND.push({ teamId: input.teamId });
} else if (ctx.user.role == "ADMIN") {
where.AND.push({ OR: [{ platform: true }, { userId: ctx.user.id }] });
@@ -1,9 +1,11 @@
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import {
updateTriggerForExistingBookings,
deleteWebhookScheduledTriggers,
cancelNoShowTasksForBooking,
} from "@calcom/features/webhooks/lib/scheduleTrigger";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
@@ -37,6 +39,23 @@ export const editHandler = async ({ input, ctx }: EditOptions) => {
}
}
if (webhook.teamId) {
const permissionService = new PermissionCheckService();
const hasPermission = await permissionService.checkPermission({
userId: ctx.user.id,
teamId: webhook.teamId,
permission: "webhook.update",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
if (!hasPermission) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
}
const updatedWebhook = await prisma.webhook.update({
where: {
id,
@@ -33,7 +33,9 @@ export type WebhooksByViewer = {
};
export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
return await WebhookRepository.getAllWebhooksByUserId({
// Use the new PBAC-aware method for fetching webhooks
return await WebhookRepository.getFilteredWebhooksForUser({
userId: ctx.user.id,
userRole: ctx.user.role,
});
@@ -1,5 +1,7 @@
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import type { TListInputSchema } from "./list.schema";
@@ -48,8 +50,26 @@ export const listHandler = async ({ ctx, input }: ListOptions) => {
where.AND?.push({ eventTypeId: input.eventTypeId });
}
} else {
const permissionService = new PermissionCheckService();
const teamIds = user?.teams?.map((m) => m.teamId) ?? [];
const allowedTeamIds = (
await Promise.all(
teamIds.map(async (teamId) => {
const ok = await permissionService.checkPermission({
userId: ctx.user.id,
teamId,
permission: "webhook.read",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
return ok ? teamId : null;
})
)
).filter((x): x is number => x !== null);
console.log("Allowed Team IDs:", allowedTeamIds);
where.AND?.push({
OR: [{ userId: ctx.user.id }, { teamId: { in: user?.teams.map((membership) => membership.teamId) } }],
OR: [{ userId: ctx.user.id }, ...(allowedTeamIds.length ? [{ teamId: { in: allowedTeamIds } }] : [])],
});
}
@@ -0,0 +1,500 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
import { prisma } from "@calcom/prisma";
import type { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import authedProcedure from "../../../procedures/authedProcedure";
// Import after mocks are set up
import { createWebhookPbacProcedure, webhookProcedure } from "./util";
// Mock dependencies - use factory functions to avoid hoisting issues
vi.mock("@calcom/prisma", () => ({
prisma: {
webhook: {
findUnique: vi.fn(),
},
eventType: {
findUnique: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
},
}));
const mockCheckPermission = vi.fn();
vi.mock("@calcom/features/pbac/services/permission-check.service", () => ({
PermissionCheckService: vi.fn().mockImplementation(() => ({
checkPermission: mockCheckPermission,
})),
}));
vi.mock("../../../procedures/authedProcedure", () => ({
default: {
input: vi.fn().mockReturnThis(),
use: vi.fn(),
},
}));
// Cast the mocked items to properly typed versions
const mockPrisma = prisma as any;
const mockAuthedProcedure = authedProcedure as any;
describe("Webhook PBAC Procedures", () => {
const mockCtx = {
user: {
id: 1,
},
};
beforeEach(() => {
vi.clearAllMocks();
// Reset the mock to ensure clean state
mockCheckPermission.mockReset();
mockPrisma.webhook.findUnique.mockReset();
mockPrisma.eventType.findUnique.mockReset();
mockPrisma.user.findUnique.mockReset();
mockAuthedProcedure.use.mockClear();
});
describe("createWebhookPbacProcedure", () => {
const testPermission: PermissionString = "webhook.update";
const fallbackRoles: MembershipRole[] = ["ADMIN", "OWNER"];
it("should create a procedure with the specified permission", () => {
const procedure = createWebhookPbacProcedure(testPermission, fallbackRoles);
// Verify that authedProcedure methods were called
expect(mockAuthedProcedure.input).toHaveBeenCalled();
expect(mockAuthedProcedure.use).toHaveBeenCalled();
});
describe("middleware behavior", () => {
let middleware: any;
beforeEach(() => {
createWebhookPbacProcedure(testPermission, fallbackRoles);
// Get the middleware function that was passed to .use()
middleware = mockAuthedProcedure.use.mock.calls[0][0];
});
describe("when webhook ID is provided", () => {
it("should allow access when user has PBAC permission for team webhook", async () => {
const mockWebhook = {
id: "webhook-1",
teamId: 10,
userId: null,
eventTypeId: null,
user: null,
team: { id: 10 },
eventType: null,
};
mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
mockCheckPermission.mockResolvedValue(true);
const next = vi.fn().mockResolvedValue("success");
const result = await middleware({
ctx: mockCtx,
input: { id: "webhook-1", teamId: 10 },
next,
});
expect(result).toBe("success");
expect(mockCheckPermission).toHaveBeenCalledWith({
userId: 1,
teamId: 10,
permission: testPermission,
fallbackRoles,
});
});
it("should throw FORBIDDEN when user lacks PBAC permission for team webhook", async () => {
const mockWebhook = {
id: "webhook-1",
teamId: 10,
userId: null,
eventTypeId: null,
user: null,
team: { id: 10 },
eventType: null,
};
mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
mockCheckPermission.mockResolvedValue(false);
const next = vi.fn();
await expect(
middleware({
ctx: mockCtx,
input: { id: "webhook-1", teamId: 10 },
next,
})
).rejects.toThrow(
new TRPCError({
code: "FORBIDDEN",
message: `Permission required: ${testPermission}`,
})
);
});
it("should allow access for personal webhook when user is the owner", async () => {
const mockWebhook = {
id: "webhook-1",
teamId: null,
userId: 1,
eventTypeId: null,
user: { id: 1 },
team: null,
eventType: null,
};
mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
const next = vi.fn().mockResolvedValue("success");
const result = await middleware({
ctx: mockCtx,
input: { id: "webhook-1" },
next,
});
expect(result).toBe("success");
expect(mockCheckPermission).not.toHaveBeenCalled();
});
it("should throw FORBIDDEN for personal webhook when user is not the owner", async () => {
const mockWebhook = {
id: "webhook-1",
teamId: null,
userId: 2,
eventTypeId: null,
user: { id: 2 },
team: null,
eventType: null,
};
mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
const next = vi.fn();
await expect(
middleware({
ctx: mockCtx,
input: { id: "webhook-1" },
next,
})
).rejects.toThrow(
new TRPCError({
code: "FORBIDDEN",
message: `Permission required: ${testPermission}`,
})
);
});
it("should check team permissions for team event type webhook", async () => {
const mockWebhook = {
id: "webhook-1",
teamId: null,
userId: null,
eventTypeId: 100,
user: null,
team: null,
eventType: { id: 100, teamId: 10, userId: 2 },
};
const mockEventType = {
id: 100,
teamId: 10,
userId: 2,
team: { id: 10 },
};
mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
mockPrisma.eventType.findUnique.mockResolvedValue(mockEventType);
mockCheckPermission.mockResolvedValue(true);
const next = vi.fn().mockResolvedValue("success");
const result = await middleware({
ctx: mockCtx,
input: { id: "webhook-1", eventTypeId: 100 },
next,
});
expect(result).toBe("success");
expect(mockCheckPermission).toHaveBeenCalledWith({
userId: 1,
teamId: 10,
permission: testPermission,
fallbackRoles,
});
});
it("should throw NOT_FOUND when webhook doesn't exist", async () => {
mockPrisma.webhook.findUnique.mockResolvedValue(null);
const next = vi.fn();
await expect(
middleware({
ctx: mockCtx,
input: { id: "webhook-1" },
next,
})
).rejects.toThrow(new TRPCError({ code: "NOT_FOUND" }));
});
it("should throw UNAUTHORIZED when teamId doesn't match webhook teamId", async () => {
const mockWebhook = {
id: "webhook-1",
teamId: 10,
userId: null,
eventTypeId: null,
user: null,
team: { id: 10 },
eventType: null,
};
mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
const next = vi.fn();
await expect(
middleware({
ctx: mockCtx,
input: { id: "webhook-1", teamId: 20 },
next,
})
).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED" }));
});
});
describe("when creating new webhook (no ID provided)", () => {
it("should allow creation with team PBAC permission", async () => {
mockCheckPermission.mockResolvedValue(true);
const next = vi.fn().mockResolvedValue("success");
const result = await middleware({
ctx: mockCtx,
input: { teamId: 10 },
next,
});
expect(result).toBe("success");
expect(mockCheckPermission).toHaveBeenCalledWith({
userId: 1,
teamId: 10,
permission: testPermission,
fallbackRoles,
});
});
it("should throw FORBIDDEN when lacking team PBAC permission", async () => {
mockCheckPermission.mockResolvedValue(false);
const next = vi.fn();
await expect(
middleware({
ctx: mockCtx,
input: { teamId: 10 },
next,
})
).rejects.toThrow(
new TRPCError({
code: "FORBIDDEN",
message: `Permission required: ${testPermission}`,
})
);
});
it("should check team permissions for team event type creation", async () => {
const mockEventType = {
id: 100,
teamId: 10,
userId: 2,
team: { id: 10 },
};
mockPrisma.eventType.findUnique.mockResolvedValue(mockEventType);
mockCheckPermission.mockResolvedValue(true);
const next = vi.fn().mockResolvedValue("success");
const result = await middleware({
ctx: mockCtx,
input: { eventTypeId: 100 },
next,
});
expect(result).toBe("success");
expect(mockCheckPermission).toHaveBeenCalledWith({
userId: 1,
teamId: 10,
permission: testPermission,
fallbackRoles,
});
});
it("should allow personal event type webhook creation for owner", async () => {
const mockEventType = {
id: 100,
teamId: null,
userId: 1,
team: null,
};
mockPrisma.eventType.findUnique.mockResolvedValue(mockEventType);
const next = vi.fn().mockResolvedValue("success");
const result = await middleware({
ctx: mockCtx,
input: { eventTypeId: 100 },
next,
});
expect(result).toBe("success");
expect(mockCheckPermission).not.toHaveBeenCalled();
});
});
describe("when no input is provided", () => {
it("should call next() directly", async () => {
const next = vi.fn().mockResolvedValue("success");
const result = await middleware({
ctx: mockCtx,
input: undefined,
next,
});
expect(result).toBe("success");
expect(next).toHaveBeenCalled();
expect(mockPrisma.webhook.findUnique).not.toHaveBeenCalled();
expect(mockPrisma.eventType.findUnique).not.toHaveBeenCalled();
});
});
});
});
describe("webhookProcedure (legacy wrapper)", () => {
it("should work as expected", () => {
// The legacy webhookProcedure uses createWebhookPbacProcedure internally
// This is verified by the functional tests above
expect(createWebhookPbacProcedure).toBeDefined();
});
});
describe("Different permission scenarios", () => {
const permissions: { permission: PermissionString; operation: string }[] = [
{ permission: "webhook.read", operation: "read" },
{ permission: "webhook.create", operation: "create" },
{ permission: "webhook.update", operation: "update" },
{ permission: "webhook.delete", operation: "delete" },
];
permissions.forEach(({ permission, operation }) => {
it(`should use ${permission} for ${operation} operations`, async () => {
createWebhookPbacProcedure(permission);
const middleware =
mockAuthedProcedure.use.mock.calls[mockAuthedProcedure.use.mock.calls.length - 1][0];
const mockWebhook = {
id: "webhook-1",
teamId: 10,
userId: null,
eventTypeId: null,
user: null,
team: { id: 10 },
eventType: null,
};
mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
mockCheckPermission.mockResolvedValue(true);
const next = vi.fn().mockResolvedValue("success");
await middleware({
ctx: mockCtx,
input: { id: "webhook-1" },
next,
});
expect(mockCheckPermission).toHaveBeenCalledWith({
userId: 1,
teamId: 10,
permission,
fallbackRoles: ["ADMIN", "OWNER"],
});
});
});
});
describe("Fallback role behavior", () => {
it("should use custom fallback roles when provided", async () => {
const customFallback: MembershipRole[] = ["OWNER"];
createWebhookPbacProcedure("webhook.delete", customFallback);
const middleware = mockAuthedProcedure.use.mock.calls[mockAuthedProcedure.use.mock.calls.length - 1][0];
const mockWebhook = {
id: "webhook-1",
teamId: 10,
userId: null,
eventTypeId: null,
user: null,
team: { id: 10 },
eventType: null,
};
mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
mockCheckPermission.mockResolvedValue(true);
const next = vi.fn().mockResolvedValue("success");
await middleware({
ctx: mockCtx,
input: { id: "webhook-1" },
next,
});
expect(mockCheckPermission).toHaveBeenCalledWith({
userId: 1,
teamId: 10,
permission: "webhook.delete",
fallbackRoles: customFallback,
});
});
it("should use default fallback roles when not provided", async () => {
createWebhookPbacProcedure("webhook.update");
const middleware = mockAuthedProcedure.use.mock.calls[mockAuthedProcedure.use.mock.calls.length - 1][0];
const mockWebhook = {
id: "webhook-1",
teamId: 10,
userId: null,
eventTypeId: null,
user: null,
team: { id: 10 },
eventType: null,
};
mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
mockCheckPermission.mockResolvedValue(true);
const next = vi.fn().mockResolvedValue("success");
await middleware({
ctx: mockCtx,
input: { id: "webhook-1" },
next,
});
expect(mockCheckPermission).toHaveBeenCalledWith({
userId: 1,
teamId: 10,
permission: "webhook.update",
fallbackRoles: ["ADMIN", "OWNER"],
});
});
});
});
@@ -1,140 +1,157 @@
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma";
import type { Membership } from "@calcom/prisma/client";
import type { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import authedProcedure from "../../../procedures/authedProcedure";
import { webhookIdAndEventTypeIdSchema } from "./types";
export const webhookProcedure = authedProcedure
.input(webhookIdAndEventTypeIdSchema.optional())
.use(async ({ ctx, input, next }) => {
// Endpoints that just read the logged in user's data - like 'list' don't necessary have any input
/**
* Creates a webhook procedure with configurable PBAC permissions
* @param permission - The specific permission required (e.g., "webhook.create", "webhook.update")
* @param fallbackRoles - Roles to check when PBAC is disabled (defaults to ["ADMIN", "OWNER"])
* @returns A procedure that checks the specified permission
*/
export const createWebhookPbacProcedure = (
permission: PermissionString,
fallbackRoles: MembershipRole[] = ["ADMIN", "OWNER"]
) => {
return authedProcedure.input(webhookIdAndEventTypeIdSchema.optional()).use(async ({ ctx, input, next }) => {
// Endpoints that just read the logged in user's data - like 'list' don't necessarily have any input
if (!input) return next();
const { id, teamId, eventTypeId } = input;
const assertPartOfTeamWithRequiredAccessLevel = (memberships?: Membership[], teamId?: number) => {
if (!memberships) return false;
if (teamId) {
return memberships.some(
(membership) => membership.teamId === teamId && checkAdminOrOwner(membership.role)
);
}
return memberships.some(
(membership) => membership.userId === ctx.user.id && checkAdminOrOwner(membership.role)
);
};
const { id, teamId, eventTypeId } = input;
const permissionCheckService = new PermissionCheckService();
if (id) {
//check if user is authorized to edit webhook
// Check if user is authorized to edit webhook
const webhook = await prisma.webhook.findUnique({
where: {
id: id,
},
include: {
user: true,
team: true,
eventType: true,
where: { id },
select: {
id: true,
userId: true,
teamId: true,
eventTypeId: true,
},
});
if (webhook) {
if (teamId && teamId !== webhook.teamId) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
if (eventTypeId && eventTypeId !== webhook.eventTypeId) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
if (webhook.teamId) {
const user = await prisma.user.findUnique({
where: {
id: ctx.user.id,
},
include: {
teams: true,
},
});
const userHasAdminOwnerPermissionInTeam =
user &&
user.teams.some(
(membership) => membership.teamId === webhook.teamId && checkAdminOrOwner(membership.role)
);
if (!userHasAdminOwnerPermissionInTeam) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
} else if (webhook.eventTypeId) {
const eventType = await prisma.eventType.findUnique({
where: {
id: webhook.eventTypeId,
},
include: {
team: {
include: {
members: true,
},
},
},
});
if (eventType && eventType.userId !== ctx.user.id) {
if (!assertPartOfTeamWithRequiredAccessLevel(eventType.team?.members)) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
}
} else if (webhook.userId && webhook.userId !== ctx.user.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
if (!webhook) {
throw new TRPCError({ code: "NOT_FOUND" });
}
} else {
//check if user is authorized to create webhook on event type or team
if (teamId) {
const user = await prisma.user.findUnique({
where: {
id: ctx.user.id,
},
include: {
teams: true,
},
// Validate consistency
if (teamId && teamId !== webhook.teamId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (eventTypeId && eventTypeId !== webhook.eventTypeId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
// For team webhooks, check PBAC permissions
if (webhook.teamId) {
const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: webhook.teamId,
permission,
fallbackRoles,
});
if (!assertPartOfTeamWithRequiredAccessLevel(user?.teams, teamId)) {
if (!hasPermission) {
throw new TRPCError({
code: "UNAUTHORIZED",
code: "FORBIDDEN",
message: `Permission required: ${permission}`,
});
}
} else if (webhook.eventTypeId) {
// For event type webhooks, check if the user owns the event type or has team permissions
const eventType = await prisma.eventType.findUnique({
where: { id: webhook.eventTypeId },
include: { team: true },
});
if (!eventType) {
throw new TRPCError({ code: "NOT_FOUND" });
}
if (eventType.userId !== ctx.user.id) {
// Check team permissions if it's a team event type
if (eventType.teamId) {
const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: eventType.teamId,
permission,
fallbackRoles,
});
if (!hasPermission) {
throw new TRPCError({
code: "FORBIDDEN",
message: `Permission required: ${permission}`,
});
}
} else {
throw new TRPCError({
code: "FORBIDDEN",
message: `Permission required: ${permission}`,
});
}
}
} else if (webhook.userId && webhook.userId !== ctx.user.id) {
// For personal webhooks, only the owner can manage
throw new TRPCError({
code: "FORBIDDEN",
message: `Permission required: ${permission}`,
});
}
} else {
// Check if user is authorized to create webhook on event type or team
if (teamId) {
const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId,
permission,
fallbackRoles,
});
if (!hasPermission) {
throw new TRPCError({
code: "FORBIDDEN",
message: `Permission required: ${permission}`,
});
}
} else if (eventTypeId) {
const eventType = await prisma.eventType.findUnique({
where: {
id: eventTypeId,
},
include: {
team: {
include: {
members: true,
},
},
},
where: { id: eventTypeId },
include: { team: true },
});
if (eventType && eventType.userId !== ctx.user.id) {
if (!assertPartOfTeamWithRequiredAccessLevel(eventType.team?.members)) {
if (!eventType) {
throw new TRPCError({ code: "NOT_FOUND" });
}
if (eventType.userId !== ctx.user.id) {
// Check team permissions if it's a team event type
if (eventType.teamId) {
const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: eventType.teamId,
permission,
fallbackRoles,
});
if (!hasPermission) {
throw new TRPCError({
code: "FORBIDDEN",
message: `Permission required: ${permission}`,
});
}
} else {
throw new TRPCError({
code: "UNAUTHORIZED",
code: "FORBIDDEN",
message: `Permission required: ${permission}`,
});
}
}
@@ -143,3 +160,10 @@ export const webhookProcedure = authedProcedure
return next();
});
};
/**
* Legacy webhook procedure - uses the new PBAC procedure with webhook.update permission
* This maintains backward compatibility while supporting PBAC
*/
export const webhookProcedure = createWebhookPbacProcedure("webhook.update", ["ADMIN", "OWNER"]);
@@ -2,11 +2,11 @@ import { faker } from "@faker-js/faker";
import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import type { Prisma } from "@calcom/prisma/client";
import { PrismaClient } from "@calcom/prisma/client";
import { BookingStatus, AssignmentReasonEnum } from "@calcom/prisma/enums";
import type { Prisma } from "./client";
import { PrismaClient } from "./client";
import { seedAttributes, seedRoutingFormResponses, seedRoutingForms } from "./seed-utils";
function getRandomRatingFeedback() {
@@ -1,11 +1,10 @@
import { uuid } from "short-uuid";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
import prisma from "@calcom/prisma";
import { MembershipRole, RoleType } from "@calcom/prisma/enums";
import prisma from ".";
/**
* Creates an organization with custom roles and PBAC (Permission-Based Access Control) enabled
* This demonstrates how to set up fine-grained permissions for team members
+2 -2
View File
@@ -11,7 +11,7 @@ import zoomMeta from "@calcom/app-store/zoomvideo/_metadata";
import dayjs from "@calcom/dayjs";
import { BookingStatus } from "@calcom/prisma/enums";
import { createUserAndEventType } from "../packages/prisma/seed-utils";
import { createUserAndEventType } from "./seed-utils";
async function _createManyDifferentUsersWithDifferentEventTypesAndBookings({
tillUser,
@@ -322,4 +322,4 @@ async function createAUserWithManyBookings() {
// startFrom: 10000,
// });
createAUserWithManyBookings();
createAUserWithManyBookings();
@@ -4,13 +4,12 @@ import { uuid } from "short-uuid";
import type z from "zod";
import dayjs from "@calcom/dayjs";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
import prisma from "@calcom/prisma";
import type { Prisma, UserPermissionRole } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import prisma from ".";
import type { Prisma, UserPermissionRole } from "./client";
import type { teamMetadataSchema } from "./zod-utils";
import type { teamMetadataSchema } from "@calcom/prisma/zod-utils";
export async function createUserAndEventType({
user,
+3 -3
View File
@@ -6,7 +6,7 @@ import googleMeetMeta from "@calcom/app-store/googlevideo/_metadata";
import zoomMeta from "@calcom/app-store/zoomvideo/_metadata";
import dayjs from "@calcom/dayjs";
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { hashPassword } from "@calcom/lib/auth/hashPassword";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
import prisma from "@calcom/prisma";
import type { Membership, Team, User, UserPermissionRole } from "@calcom/prisma/client";
@@ -14,10 +14,10 @@ import { Prisma } from "@calcom/prisma/client";
import { BookingStatus, MembershipRole, RedirectType, SchedulingType } from "@calcom/prisma/enums";
import type { Ensure } from "@calcom/types/utils";
import mainHugeEventTypesSeed from "../packages/prisma/seed-huge-event-types";
import { createUserAndEventType } from "../packages/prisma/seed-utils";
import type { teamMetadataSchema } from "../packages/prisma/zod-utils";
import mainAppStore from "./seed-app-store";
import mainHugeEventTypesSeed from "./seed-huge-event-types";
import { createUserAndEventType } from "./seed-utils";
type PlatformUser = {
email: string;
+5
View File
@@ -283,6 +283,11 @@
"_CAL_INTERNAL_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS"
],
"tasks": {
"@calcom/web#copy-app-store-static": {
"inputs": ["../../packages/app-store/**/static/**/*"],
"outputLogs": "new-only",
"outputs": ["public/app-store/**"]
},
"@calcom/prisma#build": {
"cache": false,
"dependsOn": ["post-install"]
+4 -4
View File
@@ -3822,7 +3822,6 @@ __metadata:
resolution: "@calcom/prisma@workspace:packages/prisma"
dependencies:
"@calcom/lib": "*"
"@faker-js/faker": 9.2.0
"@prisma/client": ^6.7.0
"@prisma/extension-accelerate": ^1.3.0
"@prisma/generator-helper": ^6.7.0
@@ -6032,9 +6031,9 @@ __metadata:
linkType: hard
"@faker-js/faker@npm:^7.3.0":
version: 7.4.0
resolution: "@faker-js/faker@npm:7.4.0"
checksum: 1acebb84bfb142c08e6ba2942910d16bd92ea147fa585fa2fa9ce9983f7a8c7c016002beb7fc20ef6f7aef6c4ae9cb3fa680b275823402e34802b8489d2b980d
version: 7.6.0
resolution: "@faker-js/faker@npm:7.6.0"
checksum: 942af6221774e8c98a0eb6bc75265e05fb81a941170377666c3439aab9495dd321d6beedc5406f07e6ad44262b3e43c20961f666d116ad150b78e7437dd1bb2b
languageName: node
linkType: hard
@@ -21669,6 +21668,7 @@ __metadata:
"@changesets/cli": 2.29.4
"@daily-co/daily-js": ^0.83.1
"@evyweb/ioctopus": ^1.2.0
"@faker-js/faker": 9.2.0
"@jetstreamapp/soql-parser-js": ^6.1.0
"@next/third-parties": ^14.2.5
"@playwright/test": ^1.45.3