Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfdda8a5b8 | ||
|
|
286ee6b1bd | ||
|
|
ef8b0fa41c | ||
|
|
7fbeeae25b | ||
|
|
5cf0553199 | ||
|
|
4f114ef8d3 | ||
|
|
a9d6fbd14d | ||
|
|
3e1629add9 | ||
|
|
f1ac0642d1 | ||
|
|
4689775a1f | ||
|
|
4011733271 | ||
|
|
f41aa234d7 | ||
|
|
a44a3e5b84 | ||
|
|
98a075d062 | ||
|
|
bdc3cb9d6e | ||
|
|
616e4c4908 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": patch
|
||||
---
|
||||
|
||||
fix: EventTypeSettings Checkbox booking field label
|
||||
@@ -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
@@ -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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
@@ -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" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+1
-3
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
-1
@@ -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");
|
||||
|
||||
+22
@@ -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);
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user