Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55df0e3c9d | ||
|
|
730aab3e3d | ||
|
|
63740c02c7 | ||
|
|
c5942bcfcd | ||
|
|
c4547c51a2 | ||
|
|
cf89046ede | ||
|
|
d5d44fb64b | ||
|
|
4bb092c345 | ||
|
|
e1f7e94e36 | ||
|
|
2e9d5f7df1 | ||
|
|
8c943e8817 | ||
|
|
56821553fe | ||
|
|
b512ceb217 | ||
|
|
cfeb18de3f | ||
|
|
b407be933d | ||
|
|
4e1920471d | ||
|
|
6ee752aed8 | ||
|
|
a1922efab5 | ||
|
|
76332a759b | ||
|
|
dfdda8a5b8 | ||
|
|
286ee6b1bd | ||
|
|
ef8b0fa41c | ||
|
|
7fbeeae25b | ||
|
|
5cf0553199 | ||
|
|
4f114ef8d3 | ||
|
|
a9d6fbd14d | ||
|
|
3e1629add9 | ||
|
|
f1ac0642d1 | ||
|
|
4689775a1f | ||
|
|
4011733271 | ||
|
|
f41aa234d7 | ||
|
|
a44a3e5b84 | ||
|
|
98a075d062 | ||
|
|
bdc3cb9d6e | ||
|
|
616e4c4908 | ||
|
|
eaf695824b | ||
|
|
69a76f7fb7 | ||
|
|
d70fa46260 | ||
|
|
efba58a36a | ||
|
|
b3a28dca25 | ||
|
|
3b471f0e25 | ||
|
|
aac5fa3537 | ||
|
|
134f7e591e | ||
|
|
5bb2bc3ba4 | ||
|
|
bb6f5cca93 | ||
|
|
81bd8033a9 | ||
|
|
c2a44e9319 | ||
|
|
39917ebfa6 | ||
|
|
9ee06b410e | ||
|
|
76ced2d3b8 | ||
|
|
eaa1ad6578 | ||
|
|
f9095d298d | ||
|
|
fd87954048 | ||
|
|
be249a94de | ||
|
|
59582cf8f5 | ||
|
|
5c70060992 | ||
|
|
ae7f8f1653 | ||
|
|
4d0458b0e8 | ||
|
|
55e6682832 | ||
|
|
65448c017f | ||
|
|
e9fd23d0d3 | ||
|
|
badc857bcd | ||
|
|
7fe80dd3dd | ||
|
|
5feca13cfb | ||
|
|
f267cd9cd9 | ||
|
|
e8c2d4fa95 | ||
|
|
7e415b4542 | ||
|
|
43f4caaba4 | ||
|
|
0c2ba81269 | ||
|
|
3aca339c95 | ||
|
|
41553f6c01 | ||
|
|
3661860433 | ||
|
|
f0210d06ea | ||
|
|
fcfb8cc9a3 | ||
|
|
88391c7942 | ||
|
|
e9c6af562e | ||
|
|
18bac4a063 | ||
|
|
0332118724 | ||
|
|
34b62f7516 | ||
|
|
da1863069d | ||
|
|
31270ca86b | ||
|
|
7e876e3457 | ||
|
|
cbfd991ab7 |
@@ -319,6 +319,55 @@ dates.map((date) => dayjs.utc(date).add(1, "day").format());
|
||||
dates.map((date) => new Date(date.valueOf() + 24 * 60 * 60 * 1000));
|
||||
```
|
||||
|
||||
## Next.js App Directory: Authorisation Checks in Pages
|
||||
|
||||
This can include checking session.user exists or session.org etc.
|
||||
|
||||
### TL;DR:
|
||||
|
||||
Don’t put permission checks in layout.tsx! Always put them directly inside your page.tsx or relevant server components for every restricted route.
|
||||
|
||||
|
||||
### Why Not to Use Layouts for Permission Checks
|
||||
|
||||
* Layouts don’t intercept all requests: If a user navigates directly or refreshes a protected route, layout checks might be skipped, exposing sensitive content.
|
||||
* APIs and server actions bypass layouts: Sensitive operations running on the server can’t be guarded by checks in the layout.
|
||||
* Risk of data leaks: Only page/server-level checks ensure that unauthorized users never get protected data.
|
||||
|
||||
### ✅ How To Secure Routes (The Right Way)
|
||||
|
||||
* Check permissions inside page.tsx or the actual server component.
|
||||
* Perform all session/user/role validation before querying or rendering sensitive content.
|
||||
* Redirect or return nothing to unauthorized users, before running restricted code.
|
||||
|
||||
### 🛠️ Example: Page-Level Permission Check
|
||||
|
||||
|
||||
```tsx
|
||||
// app/admin/page.tsx
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { getUserSession } from "@/lib/auth";
|
||||
|
||||
export default async function AdminPage() {
|
||||
const session = await getUserSession();
|
||||
|
||||
if (!session || session.user.role !== "admin") {
|
||||
redirect("/"); // Or show an error
|
||||
}
|
||||
|
||||
// Protected content here
|
||||
return <div>Welcome, Admin!</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### 🧠 Key Reminders
|
||||
|
||||
* Put permission guards in every restricted page.tsx.
|
||||
* Never assume layouts are secure for guarding data.
|
||||
* Validate users before any sensitive queries or rendering.
|
||||
|
||||
|
||||
## Avoid using Dayjs if you don’t need to be strictly tz aware.
|
||||
|
||||
When doing logic like Dayjs.startOf(".."), you can instead use date-fns' `startOfMonth(dateObj)` / `endOfDay(dateObj)`;
|
||||
|
||||
@@ -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
|
||||
|
||||
+82
-7
@@ -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: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
allowTypeImports: false,
|
||||
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.",
|
||||
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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -71,9 +71,9 @@ jobs:
|
||||
- name: Run Tests
|
||||
working-directory: apps/api/v2
|
||||
run: |
|
||||
yarn test:e2e
|
||||
yarn workspace @calcom/platform-libraries build && yarn test:e2e
|
||||
EXIT_CODE=$?
|
||||
echo "yarn test:e2e command exit code: $EXIT_CODE"
|
||||
echo "yarn workspace @calcom/platform-libraries build && yarn test:e2e command exit code: $EXIT_CODE"
|
||||
exit $EXIT_CODE
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
|
||||
@@ -13,6 +13,8 @@ env:
|
||||
ATOMS_E2E_API_URL: ${{ secrets.ATOMS_E2E_API_URL }}
|
||||
ATOMS_E2E_ORG_ID: ${{ secrets.ATOMS_E2E_ORG_ID }}
|
||||
ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED }}
|
||||
ATOMS_E2E_APPLE_ID: ${{ secrets.ATOMS_E2E_APPLE_ID }}
|
||||
ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE: ${{ secrets.ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
|
||||
|
||||
@@ -21,4 +21,4 @@ jobs:
|
||||
working-directory: apps/api/v2
|
||||
run: |
|
||||
export NODE_OPTIONS="--max_old_space_size=8192"
|
||||
yarn test
|
||||
yarn workspace @calcom/platform-libraries build && yarn test
|
||||
|
||||
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
@@ -13,6 +13,16 @@
|
||||
- `yarn test <filename> -- --integrationTestsOnly` - Run integration tests
|
||||
- `yarn e2e <filename> --grep "<testName>"` - Run specific E2E test
|
||||
|
||||
## Tool Preferences
|
||||
|
||||
### Search Tools Priority
|
||||
|
||||
Use tools in this order of preference:
|
||||
|
||||
1. **ast-grep** - For AST-based code searches (if available)
|
||||
2. **rg (ripgrep)** - For fast text searches
|
||||
3. **grep** - As fallback for text searches
|
||||
|
||||
## 📚 Detailed Documentation
|
||||
|
||||
- **[.agents/README.md](.agents/README.md)** - Complete development guide
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("rateLimitApiKey middleware", () => {
|
||||
query: {},
|
||||
userId: testUserId,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||
|
||||
expect(res._getStatusCode()).toBe(401);
|
||||
@@ -43,7 +43,7 @@ describe("rateLimitApiKey middleware", () => {
|
||||
query: { apiKey: "test-key" },
|
||||
userId: testUserId,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(checkRateLimitAndThrowError as any).mockResolvedValueOnce({
|
||||
limit: 100,
|
||||
remaining: 99,
|
||||
@@ -51,6 +51,7 @@ describe("rateLimitApiKey middleware", () => {
|
||||
});
|
||||
|
||||
// @ts-expect-error weird typing between middleware and createMocks
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||
|
||||
expect(checkRateLimitAndThrowError).toHaveBeenCalledWith({
|
||||
@@ -74,15 +75,14 @@ describe("rateLimitApiKey middleware", () => {
|
||||
success: true,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(checkRateLimitAndThrowError as any).mockImplementationOnce(
|
||||
({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => {
|
||||
onRateLimiterResponse(rateLimiterResponse);
|
||||
}
|
||||
);
|
||||
|
||||
// @ts-expect-error weird typing between middleware and createMocks
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||
|
||||
expect(res.getHeader("X-RateLimit-Limit")).toBe(rateLimiterResponse.limit);
|
||||
@@ -96,10 +96,11 @@ describe("rateLimitApiKey middleware", () => {
|
||||
query: { apiKey: "test-key" },
|
||||
userId: testUserId,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(checkRateLimitAndThrowError as any).mockRejectedValue(new Error("Rate limit exceeded"));
|
||||
|
||||
// @ts-expect-error weird typing between middleware and createMocks
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||
|
||||
expect(res._getStatusCode()).toBe(429);
|
||||
@@ -121,6 +122,7 @@ describe("rateLimitApiKey middleware", () => {
|
||||
};
|
||||
|
||||
// Mock rate limiter to trigger the onRateLimiterResponse callback
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(checkRateLimitAndThrowError as any).mockImplementationOnce(
|
||||
({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => {
|
||||
onRateLimiterResponse(rateLimiterResponse);
|
||||
@@ -131,6 +133,7 @@ describe("rateLimitApiKey middleware", () => {
|
||||
vi.mocked(handleAutoLock).mockResolvedValueOnce(true);
|
||||
|
||||
// @ts-expect-error weird typing between middleware and createMocks
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||
|
||||
expect(handleAutoLock).toHaveBeenCalledWith({
|
||||
@@ -158,6 +161,7 @@ describe("rateLimitApiKey middleware", () => {
|
||||
};
|
||||
|
||||
// Mock rate limiter to trigger the onRateLimiterResponse callback
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(checkRateLimitAndThrowError as any).mockImplementationOnce(
|
||||
({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => {
|
||||
onRateLimiterResponse(rateLimiterResponse);
|
||||
@@ -168,6 +172,7 @@ describe("rateLimitApiKey middleware", () => {
|
||||
vi.mocked(handleAutoLock).mockRejectedValueOnce(new Error("No user found for this API key."));
|
||||
|
||||
// @ts-expect-error weird typing between middleware and createMocks
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||
|
||||
expect(handleAutoLock).toHaveBeenCalledWith({
|
||||
@@ -195,6 +200,7 @@ describe("rateLimitApiKey middleware", () => {
|
||||
};
|
||||
|
||||
// Mock rate limiter to trigger the onRateLimiterResponse callback
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(checkRateLimitAndThrowError as any).mockImplementationOnce(
|
||||
({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => {
|
||||
onRateLimiterResponse(rateLimiterResponse);
|
||||
@@ -235,6 +241,7 @@ describe("rateLimitApiKey middleware", () => {
|
||||
);
|
||||
|
||||
// @ts-expect-error weird typing between middleware and createMocks
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||
|
||||
expect(res._getStatusCode()).toBe(429);
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -102,8 +102,11 @@ export async function getHandler(req: NextApiRequest) {
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
|
||||
attendees: true,
|
||||
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
|
||||
user: true,
|
||||
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
|
||||
payment: true,
|
||||
eventType: expand.includes("team") ? { include: { team: true } } : false,
|
||||
},
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import {
|
||||
getRecordingsOfCalVideoByRoomName,
|
||||
getDownloadLinkOfCalVideoByRecordingId,
|
||||
} from "@calcom/lib/videoClient";
|
||||
} from "@calcom/app-store/videoClient";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { RecordingItemSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { PartialReference } from "@calcom/types/EventManager";
|
||||
@@ -63,6 +63,7 @@ export async function getHandler(req: NextApiRequest) {
|
||||
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { id },
|
||||
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
|
||||
include: { references: true },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import {
|
||||
getTranscriptsAccessLinkFromRecordingId,
|
||||
checkIfRoomNameMatchesInRecording,
|
||||
} from "@calcom/lib/videoClient";
|
||||
} from "@calcom/app-store/videoClient";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { PartialReference } from "@calcom/types/EventManager";
|
||||
|
||||
@@ -61,6 +61,7 @@ export async function getHandler(req: NextApiRequest) {
|
||||
const checkIfRecordingBelongsToBooking = async (bookingId: number, recordingId: string) => {
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
|
||||
include: { references: true },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/app-store/videoClient";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/lib/videoClient";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { PartialReference } from "@calcom/types/EventManager";
|
||||
|
||||
@@ -44,6 +44,7 @@ export async function getHandler(req: NextApiRequest) {
|
||||
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { id },
|
||||
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
|
||||
include: { references: true },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { getCalendarCredentialsWithoutDelegation, getConnectedCalendars } from "@calcom/lib/CalendarManager";
|
||||
import {
|
||||
getCalendarCredentialsWithoutDelegation,
|
||||
getConnectedCalendars,
|
||||
} from "@calcom/features/calendars/lib/CalendarManager";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { getCalendarCredentialsWithoutDelegation, getConnectedCalendars } from "@calcom/lib/CalendarManager";
|
||||
import {
|
||||
getCalendarCredentialsWithoutDelegation,
|
||||
getConnectedCalendars,
|
||||
} from "@calcom/features/calendars/lib/CalendarManager";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -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,14 +54,7 @@ async function getHandler(req: NextApiRequest) {
|
||||
userId: shouldUseUserId ? { in: userIds } : undefined,
|
||||
slug: slug, // slug will be undefined if not provided in query
|
||||
},
|
||||
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,
|
||||
});
|
||||
// this really should return [], but backwards compatibility..
|
||||
if (data.length === 0) new HttpError({ statusCode: 404, message: "No event types were found" });
|
||||
@@ -69,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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export async function paymentById(
|
||||
if (safeQuery.success && method === "GET") {
|
||||
const userWithBookings = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
|
||||
include: { bookings: true },
|
||||
});
|
||||
await prisma.payment
|
||||
|
||||
@@ -75,7 +75,11 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
||||
export async function getHandler(req: NextApiRequest) {
|
||||
const { query } = req;
|
||||
const { id } = schemaQueryIdParseInt.parse(query);
|
||||
const data = await prisma.schedule.findUniqueOrThrow({ where: { id }, include: { availability: true } });
|
||||
const data = await prisma.schedule.findUniqueOrThrow({
|
||||
where: { id },
|
||||
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
|
||||
include: { availability: true },
|
||||
});
|
||||
return { schedule: schemaSchedulePublic.parse(data) };
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ export async function patchHandler(req: NextApiRequest) {
|
||||
|
||||
/** Only OWNERS and ADMINS can edit teams */
|
||||
const _team = await prisma.team.findFirst({
|
||||
// eslint-disable-next-line @calcom/eslint/no-prisma-include-true
|
||||
include: { members: true },
|
||||
where: { id: teamId, members: { some: { userId, role: { in: ["OWNER", "ADMIN"] } } } },
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { buildBooking } from "@calcom/lib/test/builder";
|
||||
import {
|
||||
getRecordingsOfCalVideoByRoomName,
|
||||
getDownloadLinkOfCalVideoByRecordingId,
|
||||
} from "@calcom/lib/videoClient";
|
||||
} from "@calcom/app-store/videoClient";
|
||||
|
||||
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||
|
||||
@@ -22,7 +22,7 @@ type CustomNextApiResponse = NextApiResponse & Response;
|
||||
const adminUserId = 1;
|
||||
const memberUserId = 10;
|
||||
|
||||
vi.mock("@calcom/lib/videoClient", () => {
|
||||
vi.mock("@calcom/app-store/videoClient", () => {
|
||||
return {
|
||||
getRecordingsOfCalVideoByRoomName: vi.fn(),
|
||||
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
|
||||
|
||||
@@ -5,11 +5,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, test, vi, afterEach } from "vitest";
|
||||
|
||||
import { buildBooking } from "@calcom/lib/test/builder";
|
||||
import {
|
||||
getTranscriptsAccessLinkFromRecordingId,
|
||||
checkIfRoomNameMatchesInRecording,
|
||||
} from "@calcom/lib/videoClient";
|
||||
} from "@calcom/app-store/videoClient";
|
||||
import { buildBooking } from "@calcom/lib/test/builder";
|
||||
|
||||
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||
|
||||
@@ -19,7 +19,7 @@ import handler from "../../../../../../pages/api/bookings/[id]/transcripts/[reco
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
vi.mock("@calcom/lib/videoClient", () => {
|
||||
vi.mock("@calcom/app-store/videoClient", () => {
|
||||
return {
|
||||
getTranscriptsAccessLinkFromRecordingId: vi.fn(),
|
||||
checkIfRoomNameMatchesInRecording: vi.fn(),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, test, vi, afterEach } from "vitest";
|
||||
|
||||
import { buildBooking } from "@calcom/lib/test/builder";
|
||||
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/lib/videoClient";
|
||||
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/app-store/videoClient";
|
||||
|
||||
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||
|
||||
@@ -16,7 +16,7 @@ import handler from "../../../../../pages/api/bookings/[id]/transcripts/_get";
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
vi.mock("@calcom/lib/videoClient", () => {
|
||||
vi.mock("@calcom/app-store/videoClient", () => {
|
||||
return {
|
||||
getAllTranscriptsAccessLinkFromRoomName: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -86,7 +86,7 @@ vi.mock("@calcom/lib/di/containers/QualifiedHosts", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/EventManager", () => ({
|
||||
vi.mock("@calcom/features/bookings/lib/EventManager", () => ({
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
reschedule: vi.fn().mockResolvedValue({
|
||||
results: [],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,8 @@ RUN yarn workspace @calcom/api-v2 run generate-schemas
|
||||
RUN rm -rf apps/api/v2/node_modules
|
||||
RUN yarn install
|
||||
|
||||
RUN yarn workspace @calcom/platform-libraries run build
|
||||
|
||||
RUN yarn workspace @calcom/api-v2 run build
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.359",
|
||||
"@calcom/platform-libraries": "*",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
|
||||
@@ -11,7 +11,12 @@ import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/se
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
|
||||
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import {
|
||||
API_KEY_OR_ACCESS_TOKEN_HEADER,
|
||||
OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER,
|
||||
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
|
||||
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
|
||||
} from "@/lib/docs/headers";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import {
|
||||
AuthOptionalUser,
|
||||
@@ -22,8 +27,8 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
@@ -101,6 +106,9 @@ export class BookingsController_2024_08_13 {
|
||||
|
||||
@Post("/")
|
||||
@UseGuards(OptionalApiAuthGuard)
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Create a booking",
|
||||
description: `
|
||||
@@ -122,6 +130,9 @@ export class BookingsController_2024_08_13 {
|
||||
And 2 ways to book and event type belonging to a team:
|
||||
1. Provide \`eventTypeId\` in the request body.
|
||||
2. Provide \`eventTypeSlug\` and \`teamSlug\` and optionally \`organizationSlug\` if the team with the teamSlug is within an organization.
|
||||
|
||||
If you are creating a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
|
||||
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
|
||||
`,
|
||||
})
|
||||
@ApiBody({
|
||||
@@ -161,7 +172,10 @@ export class BookingsController_2024_08_13 {
|
||||
}
|
||||
|
||||
@Get("/:bookingUid")
|
||||
@UseGuards(BookingUidGuard)
|
||||
@UseGuards(BookingUidGuard, OptionalApiAuthGuard)
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Get a booking",
|
||||
description: `\`:bookingUid\` can be
|
||||
@@ -170,10 +184,17 @@ export class BookingsController_2024_08_13 {
|
||||
|
||||
2. uid of one of the recurring booking recurrences
|
||||
|
||||
3. uid of recurring booking which will return an array of all recurring booking recurrences (stored as recurringBookingUid on one of the individual recurrences).`,
|
||||
3. uid of recurring booking which will return an array of all recurring booking recurrences (stored as recurringBookingUid on one of the individual recurrences).
|
||||
|
||||
If you are fetching a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
|
||||
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
|
||||
`,
|
||||
})
|
||||
async getBooking(@Param("bookingUid") bookingUid: string): Promise<GetBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.getBooking(bookingUid);
|
||||
async getBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@GetOptionalUser() user: AuthOptionalUser
|
||||
): Promise<GetBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.getBooking(bookingUid, user);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
@@ -218,7 +239,7 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({ summary: "Get all bookings" })
|
||||
async getBookings(
|
||||
@Query() queryParams: GetBookingsInput_2024_08_13,
|
||||
@GetUser() user: UserWithProfile
|
||||
@GetUser() user: ApiAuthGuardUser
|
||||
): Promise<GetBookingsOutput_2024_08_13> {
|
||||
const profile = this.usersService.getUserMainProfile(user);
|
||||
|
||||
@@ -236,7 +257,10 @@ export class BookingsController_2024_08_13 {
|
||||
}
|
||||
|
||||
@Post("/:bookingUid/reschedule")
|
||||
@UseGuards(BookingUidGuard)
|
||||
@UseGuards(BookingUidGuard, OptionalApiAuthGuard)
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Reschedule a booking",
|
||||
description: "Reschedule a booking or seated booking",
|
||||
@@ -248,17 +272,20 @@ export class BookingsController_2024_08_13 {
|
||||
{ $ref: getSchemaPath(RescheduleSeatedBookingInput_2024_08_13) },
|
||||
],
|
||||
},
|
||||
description:
|
||||
"Accepts different types of reschedule booking input: Reschedule Booking (Option 1) or Reschedule Seated Booking (Option 2)",
|
||||
description: `Accepts different types of reschedule booking input: Reschedule Booking (Option 1) or Reschedule Seated Booking (Option 2).
|
||||
|
||||
If you are rescheduling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
|
||||
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.`,
|
||||
})
|
||||
@ApiExtraModels(RescheduleBookingInput_2024_08_13, RescheduleSeatedBookingInput_2024_08_13)
|
||||
async rescheduleBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Body(new RescheduleBookingInputPipe())
|
||||
body: RescheduleBookingInput,
|
||||
@Req() request: Request
|
||||
@Req() request: Request,
|
||||
@GetOptionalUser() user: AuthOptionalUser
|
||||
): Promise<RescheduleBookingOutput_2024_08_13> {
|
||||
const newBooking = await this.bookingsService.rescheduleBooking(request, bookingUid, body);
|
||||
const newBooking = await this.bookingsService.rescheduleBooking(request, bookingUid, body, user);
|
||||
await this.bookingsService.billRescheduledBooking(newBooking, bookingUid);
|
||||
|
||||
return {
|
||||
@@ -268,7 +295,10 @@ export class BookingsController_2024_08_13 {
|
||||
}
|
||||
|
||||
@Post("/:bookingUid/cancel")
|
||||
@UseGuards(BookingUidGuard)
|
||||
@UseGuards(BookingUidGuard, OptionalApiAuthGuard)
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "Cancel a booking",
|
||||
@@ -281,7 +311,11 @@ export class BookingsController_2024_08_13 {
|
||||
|
||||
\nCancelling recurring seated bookings:
|
||||
For recurring seated bookings it is not possible to cancel all of them with 1 call
|
||||
like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.`,
|
||||
like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.
|
||||
|
||||
If you are cancelling a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
|
||||
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
|
||||
`,
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
@@ -298,9 +332,10 @@ export class BookingsController_2024_08_13 {
|
||||
@Req() request: Request,
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Body(new CancelBookingInputPipe())
|
||||
body: CancelBookingInput
|
||||
body: CancelBookingInput,
|
||||
@GetOptionalUser() user: AuthOptionalUser
|
||||
): Promise<CancelBookingOutput_2024_08_13> {
|
||||
const cancelledBooking = await this.bookingsService.cancelBooking(request, bookingUid, body);
|
||||
const cancelledBooking = await this.bookingsService.cancelBooking(request, bookingUid, body, user);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
@@ -342,7 +377,7 @@ export class BookingsController_2024_08_13 {
|
||||
})
|
||||
async reassignBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@GetUser() user: UserWithProfile
|
||||
@GetUser() user: ApiAuthGuardUser
|
||||
): Promise<ReassignBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.reassignBooking(bookingUid, user);
|
||||
|
||||
@@ -392,7 +427,7 @@ export class BookingsController_2024_08_13 {
|
||||
})
|
||||
async confirmBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@GetUser() user: UserWithProfile
|
||||
@GetUser() user: ApiAuthGuardUser
|
||||
): Promise<GetBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.confirmBooking(bookingUid, user);
|
||||
|
||||
@@ -414,7 +449,7 @@ export class BookingsController_2024_08_13 {
|
||||
async declineBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Body() body: DeclineBookingInput_2024_08_13,
|
||||
@GetUser() user: UserWithProfile
|
||||
@GetUser() user: ApiAuthGuardUser
|
||||
): Promise<GetBookingOutput_2024_08_13> {
|
||||
const booking = await this.bookingsService.declineBooking(bookingUid, user, body.reason);
|
||||
|
||||
|
||||
+201
-53
@@ -437,61 +437,209 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should book an event type with attendees disabled", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)).toISOString(),
|
||||
eventTypeId: seatedEventTypeIdAttendeesDisabledId,
|
||||
attendee: {
|
||||
name: nameAttendeeOne,
|
||||
email: emailAttendeeOne,
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
bookingFieldsResponses: {
|
||||
codingLanguage: "TypeScript",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
};
|
||||
describe("book an event type with attendees disabled but auth provided", () => {
|
||||
let booking: CreateSeatedBookingOutput_2024_08_13;
|
||||
it("should book an event type with attendees disabled and no auth provided", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)).toISOString(),
|
||||
eventTypeId: seatedEventTypeIdAttendeesDisabledId,
|
||||
attendee: {
|
||||
name: nameAttendeeOne,
|
||||
email: emailAttendeeOne,
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
bookingFieldsResponses: {
|
||||
codingLanguage: "TypeScript",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true);
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsCreateSeatedBooking(responseBody.data)) {
|
||||
const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.seatUid).toBeDefined();
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(
|
||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
||||
);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeIdAttendeesDisabledId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeIdAttendeesDisabledId,
|
||||
slug: seatedEventSlugAttendeesDisabled,
|
||||
});
|
||||
expect(data.attendees.length).toEqual(0);
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected seated booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
if (responseDataIsCreateSeatedBooking(responseBody.data)) {
|
||||
const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.seatUid).toBeDefined();
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(
|
||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
||||
);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeIdAttendeesDisabledId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeIdAttendeesDisabledId,
|
||||
slug: seatedEventSlugAttendeesDisabled,
|
||||
});
|
||||
expect(data.attendees.length).toEqual(0);
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
booking = data;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected seated booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should fetch booking and not have attendees because no auth provided", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${booking.uid}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsGetSeatedBooking(responseBody.data)) {
|
||||
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts.length).toEqual(1);
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(booking.start);
|
||||
expect(data.end).toEqual(booking.end);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeIdAttendeesDisabledId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeIdAttendeesDisabledId,
|
||||
slug: seatedEventSlugAttendeesDisabled,
|
||||
});
|
||||
expect(data.attendees.length).toEqual(0);
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
await bookingsRepositoryFixture.deleteById(data.id);
|
||||
} else {
|
||||
throw new Error("Invalid response data - expected booking but received array response");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("book an event type with attendees disabled but auth provided", () => {
|
||||
let booking: CreateSeatedBookingOutput_2024_08_13;
|
||||
|
||||
it("should book an event type with attendees disabled and owner auth provided", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)).toISOString(),
|
||||
eventTypeId: seatedEventTypeIdAttendeesDisabledId,
|
||||
attendee: {
|
||||
name: nameAttendeeOne,
|
||||
email: emailAttendeeOne,
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
bookingFieldsResponses: {
|
||||
codingLanguage: "TypeScript",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", apiKeyString)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsCreateSeatedBooking(responseBody.data)) {
|
||||
const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.seatUid).toBeDefined();
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(
|
||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
||||
);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeIdAttendeesDisabledId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeIdAttendeesDisabledId,
|
||||
slug: seatedEventSlugAttendeesDisabled,
|
||||
});
|
||||
expect(data.attendees.length).toEqual(1);
|
||||
expect(data.attendees[0].email).toEqual(emailAttendeeOne);
|
||||
expect(data.attendees[0].name).toEqual(nameAttendeeOne);
|
||||
expect(data.attendees[0].language).toEqual("it");
|
||||
expect(data.attendees[0].seatUid).toBeDefined();
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
booking = data;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected seated booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should fetch booking and have attendees", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${booking.uid}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", apiKeyString)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsGetSeatedBooking(responseBody.data)) {
|
||||
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts.length).toEqual(1);
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(booking.start);
|
||||
expect(data.end).toEqual(booking.end);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeIdAttendeesDisabledId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeIdAttendeesDisabledId,
|
||||
slug: seatedEventSlugAttendeesDisabled,
|
||||
});
|
||||
expect(data.attendees.length).toEqual(1);
|
||||
expect(data.attendees[0].email).toEqual(emailAttendeeOne);
|
||||
expect(data.attendees[0].name).toEqual(nameAttendeeOne);
|
||||
expect(data.attendees[0].language).toEqual("it");
|
||||
expect(data.attendees[0].seatUid).toBeDefined();
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
await bookingsRepositoryFixture.deleteById(data.id);
|
||||
} else {
|
||||
throw new Error("Invalid response data - expected booking but received array response");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel seated booking", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { getPagination } from "@/lib/pagination/pagination";
|
||||
import { AuthOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
|
||||
@@ -120,7 +121,10 @@ export class BookingsService_2024_08_13 {
|
||||
if (!eventType) {
|
||||
this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body);
|
||||
}
|
||||
await this.checkBookingRequiresAuthenticationSetting(eventType, authUser);
|
||||
const userIsEventTypeAdminOrOwner = authUser
|
||||
? await this.userIsEventTypeAdminOrOwner(authUser, eventType)
|
||||
: false;
|
||||
await this.checkBookingRequiresAuthenticationSetting(eventType, authUser, userIsEventTypeAdminOrOwner);
|
||||
|
||||
if (eventType.schedulingType === "MANAGED") {
|
||||
throw new BadRequestException(
|
||||
@@ -140,13 +144,13 @@ export class BookingsService_2024_08_13 {
|
||||
await this.hasRequiredBookingFieldsResponses(body, eventType);
|
||||
|
||||
if (isRecurring && isSeated) {
|
||||
return await this.createRecurringSeatedBooking(request, body, eventType);
|
||||
return await this.createRecurringSeatedBooking(request, body, eventType, userIsEventTypeAdminOrOwner);
|
||||
}
|
||||
if (isRecurring && !isSeated) {
|
||||
return await this.createRecurringBooking(request, body, eventType);
|
||||
}
|
||||
if (isSeated) {
|
||||
return await this.createSeatedBooking(request, body, eventType);
|
||||
return await this.createSeatedBooking(request, body, eventType, userIsEventTypeAdminOrOwner);
|
||||
}
|
||||
|
||||
return await this.createRegularBooking(request, body, eventType);
|
||||
@@ -157,7 +161,8 @@ export class BookingsService_2024_08_13 {
|
||||
|
||||
async checkBookingRequiresAuthenticationSetting(
|
||||
eventType: EventTypeWithOwnerAndTeam,
|
||||
authUser: AuthOptionalUser
|
||||
authUser: AuthOptionalUser,
|
||||
userIsEventTypeAdminOrOwner: boolean
|
||||
) {
|
||||
if (!eventType.bookingRequiresAuthentication) return true;
|
||||
if (!authUser) {
|
||||
@@ -166,15 +171,23 @@ export class BookingsService_2024_08_13 {
|
||||
);
|
||||
}
|
||||
|
||||
if (!userIsEventTypeAdminOrOwner) {
|
||||
throw new ForbiddenException(
|
||||
"checkBookingRequiresAuthentication - user is not authorized to access this event type. User has to be either event type owner, host, team admin or owner or org admin or owner."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async userIsEventTypeAdminOrOwner(authUser: ApiAuthGuardUser, eventType: EventType) {
|
||||
const authUserId = authUser.id;
|
||||
const authUserRole = authUser.role;
|
||||
const eventTypeId = eventType.id;
|
||||
const teamId = eventType.teamId;
|
||||
const bookingUserId = eventType.owner?.id || null;
|
||||
const eventTypeOwnerId = eventType.userId || null;
|
||||
|
||||
if (authUserRole === "ADMIN") return;
|
||||
if (authUserRole === "ADMIN") return true;
|
||||
|
||||
if (bookingUserId === authUserId) return;
|
||||
if (eventTypeOwnerId === authUserId) return true;
|
||||
|
||||
if (eventTypeId) {
|
||||
const [isUserHost, isUserAssigned] = await Promise.all([
|
||||
@@ -182,7 +195,7 @@ export class BookingsService_2024_08_13 {
|
||||
this.eventTypesRepository.isUserAssignedToEventType(authUserId, eventTypeId),
|
||||
]);
|
||||
|
||||
if (isUserHost || isUserAssigned) return;
|
||||
if (isUserHost || isUserAssigned) return true;
|
||||
}
|
||||
|
||||
if (teamId) {
|
||||
@@ -190,19 +203,17 @@ export class BookingsService_2024_08_13 {
|
||||
authUserId,
|
||||
teamId
|
||||
);
|
||||
if (membership) return;
|
||||
if (membership) return true;
|
||||
}
|
||||
|
||||
if (
|
||||
bookingUserId &&
|
||||
(await this.membershipsService.isUserOrgAdminOrOwnerOfAnotherUser(authUserId, bookingUserId))
|
||||
eventTypeOwnerId &&
|
||||
(await this.membershipsService.isUserOrgAdminOrOwnerOfAnotherUser(authUserId, eventTypeOwnerId))
|
||||
) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new ForbiddenException(
|
||||
"checkBookingRequiresAuthentication - user is not authorized to access this event type. User has to be either event type owner, host, team admin or owner or org admin or owner."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
async getBookedEventType(body: CreateBookingInput) {
|
||||
@@ -476,7 +487,8 @@ export class BookingsService_2024_08_13 {
|
||||
async createRecurringSeatedBooking(
|
||||
request: Request,
|
||||
body: CreateRecurringBookingInput_2024_08_13,
|
||||
eventType: EventTypeWithOwnerAndTeam
|
||||
eventType: EventTypeWithOwnerAndTeam,
|
||||
userIsEventTypeAdminOrOwner: boolean
|
||||
) {
|
||||
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType);
|
||||
const bookings = await handleNewRecurringBooking({
|
||||
@@ -491,7 +503,8 @@ export class BookingsService_2024_08_13 {
|
||||
areCalendarEventsEnabled: bookingRequest.areCalendarEventsEnabled,
|
||||
});
|
||||
return this.outputService.getOutputCreateRecurringSeatedBookings(
|
||||
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" }))
|
||||
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" })),
|
||||
userIsEventTypeAdminOrOwner
|
||||
);
|
||||
}
|
||||
|
||||
@@ -528,7 +541,8 @@ export class BookingsService_2024_08_13 {
|
||||
async createSeatedBooking(
|
||||
request: Request,
|
||||
body: CreateBookingInput_2024_08_13,
|
||||
eventType: EventTypeWithOwnerAndTeam
|
||||
eventType: EventTypeWithOwnerAndTeam,
|
||||
userIsEventTypeAdminOrOwner: boolean
|
||||
) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
||||
try {
|
||||
@@ -554,7 +568,11 @@ export class BookingsService_2024_08_13 {
|
||||
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
|
||||
}
|
||||
|
||||
return this.outputService.getOutputCreateSeatedBooking(databaseBooking, booking.seatReferenceUid || "");
|
||||
return this.outputService.getOutputCreateSeatedBooking(
|
||||
databaseBooking,
|
||||
booking.seatReferenceUid || "",
|
||||
userIsEventTypeAdminOrOwner
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "booking_seats_full_error") {
|
||||
@@ -565,8 +583,12 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
}
|
||||
|
||||
async getBooking(uid: string) {
|
||||
async getBooking(uid: string, authUser: AuthOptionalUser) {
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(uid);
|
||||
const userIsEventTypeAdminOrOwner =
|
||||
authUser && booking?.eventType
|
||||
? await this.userIsEventTypeAdminOrOwner(authUser, booking.eventType)
|
||||
: false;
|
||||
|
||||
if (booking) {
|
||||
const isRecurring = !!booking.recurringEventId;
|
||||
@@ -576,13 +598,12 @@ export class BookingsService_2024_08_13 {
|
||||
return this.outputService.getOutputRecurringBooking(booking);
|
||||
}
|
||||
if (isRecurring && isSeated) {
|
||||
return this.outputService.getOutputRecurringSeatedBooking(
|
||||
booking,
|
||||
!!booking.eventType?.seatsShowAttendees
|
||||
);
|
||||
const showAttendees = userIsEventTypeAdminOrOwner || !!booking.eventType?.seatsShowAttendees;
|
||||
return this.outputService.getOutputRecurringSeatedBooking(booking, showAttendees);
|
||||
}
|
||||
if (isSeated) {
|
||||
return this.outputService.getOutputSeatedBooking(booking, !!booking.eventType?.seatsShowAttendees);
|
||||
const showAttendees = userIsEventTypeAdminOrOwner || !!booking.eventType?.seatsShowAttendees;
|
||||
return this.outputService.getOutputSeatedBooking(booking, showAttendees);
|
||||
}
|
||||
return this.outputService.getOutputBooking(booking);
|
||||
}
|
||||
@@ -594,10 +615,9 @@ export class BookingsService_2024_08_13 {
|
||||
const ids = recurringBooking.map((booking) => booking.id);
|
||||
const isRecurringSeated = !!recurringBooking[0].eventType?.seatsPerTimeSlot;
|
||||
if (isRecurringSeated) {
|
||||
return this.outputService.getOutputRecurringSeatedBookings(
|
||||
ids,
|
||||
!!recurringBooking[0].eventType?.seatsShowAttendees
|
||||
);
|
||||
const showAttendees =
|
||||
userIsEventTypeAdminOrOwner || !!recurringBooking[0].eventType?.seatsShowAttendees;
|
||||
return this.outputService.getOutputRecurringSeatedBookings(ids, showAttendees);
|
||||
}
|
||||
|
||||
return this.outputService.getOutputRecurringBookings(ids);
|
||||
@@ -663,19 +683,9 @@ export class BookingsService_2024_08_13 {
|
||||
if (isRecurring && !isSeated) {
|
||||
formattedBookings.push(this.outputService.getOutputRecurringBooking(formatted));
|
||||
} else if (isRecurring && isSeated) {
|
||||
formattedBookings.push(
|
||||
this.outputService.getOutputRecurringSeatedBooking(
|
||||
formatted,
|
||||
!!formatted.eventType?.seatsShowAttendees
|
||||
)
|
||||
);
|
||||
formattedBookings.push(this.outputService.getOutputRecurringSeatedBooking(formatted, true));
|
||||
} else if (isSeated) {
|
||||
formattedBookings.push(
|
||||
await this.outputService.getOutputSeatedBooking(
|
||||
formatted,
|
||||
!!formatted.eventType?.seatsShowAttendees
|
||||
)
|
||||
);
|
||||
formattedBookings.push(await this.outputService.getOutputSeatedBooking(formatted, true));
|
||||
} else {
|
||||
formattedBookings.push(await this.outputService.getOutputBooking(formatted));
|
||||
}
|
||||
@@ -718,7 +728,12 @@ export class BookingsService_2024_08_13 {
|
||||
return queryParamsAttendeeEmail;
|
||||
}
|
||||
|
||||
async rescheduleBooking(request: Request, bookingUid: string, body: RescheduleBookingInput) {
|
||||
async rescheduleBooking(
|
||||
request: Request,
|
||||
bookingUid: string,
|
||||
body: RescheduleBookingInput,
|
||||
authUser: AuthOptionalUser
|
||||
) {
|
||||
try {
|
||||
await this.canRescheduleBooking(bookingUid);
|
||||
const bookingRequest = await this.inputService.createRescheduleBookingRequest(
|
||||
@@ -747,6 +762,10 @@ export class BookingsService_2024_08_13 {
|
||||
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
|
||||
}
|
||||
|
||||
const userIsEventTypeAdminOrOwner =
|
||||
authUser && databaseBooking.eventType
|
||||
? await this.userIsEventTypeAdminOrOwner(authUser, databaseBooking.eventType)
|
||||
: false;
|
||||
const isRecurring = !!databaseBooking.recurringEventId;
|
||||
const isSeated = !!databaseBooking.eventType?.seatsPerTimeSlot;
|
||||
|
||||
@@ -756,13 +775,15 @@ export class BookingsService_2024_08_13 {
|
||||
if (isRecurring && isSeated) {
|
||||
return this.outputService.getOutputCreateRecurringSeatedBooking(
|
||||
databaseBooking,
|
||||
booking?.seatReferenceUid || ""
|
||||
booking?.seatReferenceUid || "",
|
||||
userIsEventTypeAdminOrOwner
|
||||
);
|
||||
}
|
||||
if (isSeated) {
|
||||
return this.outputService.getOutputCreateSeatedBooking(
|
||||
databaseBooking,
|
||||
booking.seatReferenceUid || ""
|
||||
booking.seatReferenceUid || "",
|
||||
userIsEventTypeAdminOrOwner
|
||||
);
|
||||
}
|
||||
return this.outputService.getOutputBooking(databaseBooking);
|
||||
@@ -790,7 +811,12 @@ export class BookingsService_2024_08_13 {
|
||||
return booking;
|
||||
}
|
||||
|
||||
async cancelBooking(request: Request, bookingUid: string, body: CancelBookingInput) {
|
||||
async cancelBooking(
|
||||
request: Request,
|
||||
bookingUid: string,
|
||||
body: CancelBookingInput,
|
||||
authUser: AuthOptionalUser
|
||||
) {
|
||||
if (this.inputService.isCancelSeatedBody(body)) {
|
||||
const seat = await this.bookingSeatRepository.getByReferenceUid(body.seatUid);
|
||||
|
||||
@@ -821,13 +847,13 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
|
||||
if ("cancelSubsequentBookings" in body && body.cancelSubsequentBookings) {
|
||||
return this.getAllRecurringBookingsByIndividualUid(bookingUid);
|
||||
return this.getAllRecurringBookingsByIndividualUid(bookingUid, authUser);
|
||||
}
|
||||
|
||||
return this.getBooking(bookingUid);
|
||||
return this.getBooking(bookingUid, authUser);
|
||||
}
|
||||
|
||||
private async getAllRecurringBookingsByIndividualUid(bookingUid: string) {
|
||||
private async getAllRecurringBookingsByIndividualUid(bookingUid: string, authUser: AuthOptionalUser) {
|
||||
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
||||
const recurringBookingUid = booking?.recurringEventId;
|
||||
if (!recurringBookingUid) {
|
||||
@@ -836,7 +862,7 @@ export class BookingsService_2024_08_13 {
|
||||
);
|
||||
}
|
||||
|
||||
return await this.getBooking(recurringBookingUid);
|
||||
return await this.getBooking(recurringBookingUid, authUser);
|
||||
}
|
||||
|
||||
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
|
||||
@@ -1003,7 +1029,7 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
}
|
||||
|
||||
async confirmBooking(bookingUid: string, requestUser: UserWithProfile) {
|
||||
async confirmBooking(bookingUid: string, requestUser: ApiAuthGuardUser) {
|
||||
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
@@ -1032,10 +1058,10 @@ export class BookingsService_2024_08_13 {
|
||||
},
|
||||
});
|
||||
|
||||
return this.getBooking(bookingUid);
|
||||
return this.getBooking(bookingUid, requestUser);
|
||||
}
|
||||
|
||||
async declineBooking(bookingUid: string, requestUser: UserWithProfile, reason?: string) {
|
||||
async declineBooking(bookingUid: string, requestUser: ApiAuthGuardUser, reason?: string) {
|
||||
const booking = await this.bookingsRepository.getByUid(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
@@ -1065,7 +1091,7 @@ export class BookingsService_2024_08_13 {
|
||||
},
|
||||
});
|
||||
|
||||
return this.getBooking(bookingUid);
|
||||
return this.getBooking(bookingUid, requestUser);
|
||||
}
|
||||
|
||||
async getCalendarLinks(bookingUid: string): Promise<CalendarLink[]> {
|
||||
|
||||
@@ -229,7 +229,8 @@ export class OutputBookingsService_2024_08_13 {
|
||||
status: databaseBooking.status.toLowerCase(),
|
||||
cancellationReason:
|
||||
databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancellationReason : undefined,
|
||||
cancelledByEmail: databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancelledBy : undefined,
|
||||
cancelledByEmail:
|
||||
databaseBooking.status === BookingStatus.CANCELLED ? databaseBooking.cancelledBy : undefined,
|
||||
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||
start: databaseBooking.startTime,
|
||||
@@ -269,12 +270,11 @@ export class OutputBookingsService_2024_08_13 {
|
||||
|
||||
async getOutputCreateSeatedBooking(
|
||||
databaseBooking: DatabaseBooking,
|
||||
seatUid: string
|
||||
seatUid: string,
|
||||
userIsEventTypeAdminOrOwner: boolean
|
||||
): Promise<CreateSeatedBookingOutput_2024_08_13> {
|
||||
const getSeatedBookingOutput = await this.getOutputSeatedBooking(
|
||||
databaseBooking,
|
||||
!!databaseBooking.eventType?.seatsShowAttendees
|
||||
);
|
||||
const showAttendees = userIsEventTypeAdminOrOwner || !!databaseBooking.eventType?.seatsShowAttendees;
|
||||
const getSeatedBookingOutput = await this.getOutputSeatedBooking(databaseBooking, showAttendees);
|
||||
return { ...getSeatedBookingOutput, seatUid };
|
||||
}
|
||||
|
||||
@@ -377,7 +377,10 @@ export class OutputBookingsService_2024_08_13 {
|
||||
return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
||||
}
|
||||
|
||||
async getOutputCreateRecurringSeatedBookings(bookings: { uid: string; seatUid: string }[]) {
|
||||
async getOutputCreateRecurringSeatedBookings(
|
||||
bookings: { uid: string; seatUid: string }[],
|
||||
userIsEventTypeAdminOrOwner: boolean
|
||||
) {
|
||||
const transformed = [];
|
||||
|
||||
for (const booking of bookings) {
|
||||
@@ -386,7 +389,13 @@ export class OutputBookingsService_2024_08_13 {
|
||||
if (!databaseBooking) {
|
||||
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
|
||||
}
|
||||
transformed.push(this.getOutputCreateRecurringSeatedBooking(databaseBooking, booking.seatUid));
|
||||
transformed.push(
|
||||
this.getOutputCreateRecurringSeatedBooking(
|
||||
databaseBooking,
|
||||
booking.seatUid,
|
||||
userIsEventTypeAdminOrOwner
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
||||
@@ -394,11 +403,13 @@ export class OutputBookingsService_2024_08_13 {
|
||||
|
||||
getOutputCreateRecurringSeatedBooking(
|
||||
databaseBooking: DatabaseBooking,
|
||||
seatUid: string
|
||||
seatUid: string,
|
||||
userIsEventTypeAdminOrOwner: boolean
|
||||
): CreateRecurringSeatedBookingOutput_2024_08_13 {
|
||||
const showAttendees = userIsEventTypeAdminOrOwner || !!databaseBooking.eventType?.seatsShowAttendees;
|
||||
const getRecurringSeatedBookingOutput = this.getOutputRecurringSeatedBooking(
|
||||
databaseBooking,
|
||||
!!databaseBooking.eventType?.seatsShowAttendees
|
||||
showAttendees
|
||||
);
|
||||
return { ...getRecurringSeatedBookingOutput, seatUid };
|
||||
}
|
||||
|
||||
+12
@@ -1141,6 +1141,9 @@ describe("Event types Endpoints", () => {
|
||||
disableRecordingForGuests: true,
|
||||
disableRecordingForOrganizer: true,
|
||||
enableAutomaticRecordingForOrganizer: true,
|
||||
enableAutomaticTranscription: true,
|
||||
disableTranscriptionForGuests: true,
|
||||
disableTranscriptionForOrganizer: true,
|
||||
},
|
||||
bookingFields: [
|
||||
nameBookingField,
|
||||
@@ -1274,6 +1277,15 @@ describe("Event types Endpoints", () => {
|
||||
expect(updatedEventType.calVideoSettings?.enableAutomaticRecordingForOrganizer).toEqual(
|
||||
body.calVideoSettings?.enableAutomaticRecordingForOrganizer
|
||||
);
|
||||
expect(updatedEventType.calVideoSettings?.enableAutomaticTranscription).toEqual(
|
||||
body.calVideoSettings?.enableAutomaticTranscription
|
||||
);
|
||||
expect(updatedEventType.calVideoSettings?.disableTranscriptionForGuests).toEqual(
|
||||
body.calVideoSettings?.disableTranscriptionForGuests
|
||||
);
|
||||
expect(updatedEventType.calVideoSettings?.disableTranscriptionForOrganizer).toEqual(
|
||||
body.calVideoSettings?.disableTranscriptionForOrganizer
|
||||
);
|
||||
|
||||
eventType.title = newTitle;
|
||||
eventType.scheduleId = secondSchedule.id;
|
||||
|
||||
@@ -32,7 +32,7 @@ import { randomString } from "test/utils/randomString";
|
||||
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
import type { PlatformOAuthClient, Team, User } from "@calcom/prisma/client";
|
||||
|
||||
import { ApiAuthGuardRequest } from "./api-auth.strategy";
|
||||
import { ApiAuthGuardRequest, ONLY_CLIENT_ID_PROVIDED_MESSAGE } from "./api-auth.strategy";
|
||||
import { ApiAuthStrategy } from "./api-auth.strategy";
|
||||
|
||||
describe("ApiAuthStrategy", () => {
|
||||
@@ -244,6 +244,29 @@ describe("ApiAuthStrategy", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw 401 if only OAuth ID is provided", async () => {
|
||||
const context: ExecutionContext = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({
|
||||
headers: {
|
||||
[X_CAL_CLIENT_ID]: `${oAuthClient.id}gibberish`,
|
||||
},
|
||||
get: (key: string) => ({ origin: "http://localhost:3000" }[key]),
|
||||
}),
|
||||
}),
|
||||
} as ExecutionContext;
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
try {
|
||||
await strategy.authenticate(request);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) {
|
||||
expect(error.getStatus()).toEqual(401);
|
||||
expect(error.message).toContain(ONLY_CLIENT_ID_PROVIDED_MESSAGE);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw 401 if OAuth ID is invalid", async () => {
|
||||
const context: ExecutionContext = {
|
||||
switchToHttp: () => ({
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Delete,
|
||||
BadRequestException,
|
||||
ParseIntPipe,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiExcludeController } from "@nestjs/swagger";
|
||||
@@ -51,7 +51,7 @@ export class BillingController {
|
||||
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
||||
@MembershipRoles(["OWNER", "ADMIN", "MEMBER"])
|
||||
async checkTeamBilling(
|
||||
@Param("teamId") teamId: number
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<ApiResponse<CheckPlatformBillingResponseDto>> {
|
||||
const { status, plan } = await this.billingService.getBillingData(teamId);
|
||||
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ import { plainToClass } from "class-transformer";
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
|
||||
import type { CreateDelegationCredentialInput } from "./inputs/create-delegation-credential.input";
|
||||
import { CreateDelegationCredentialInput } from "./inputs/create-delegation-credential.input";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/delegation-credentials",
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ import type {
|
||||
import type { User, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Event Types Endpoints", () => {
|
||||
describe("User Authentication - User is Org Admin", () => {
|
||||
describe("User Authentication - User is Org Admin but not team admin", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
+1238
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -134,7 +134,7 @@ export async function getCRMData(
|
||||
|
||||
if (!teamMemberEmail || !crmOwnerRecordType || !crmAppSlug) {
|
||||
const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import(
|
||||
"@calcom/lib/server/getTeamMemberEmailFromCrm"
|
||||
"@calcom/features/ee/teams/lib/getTeamMemberEmailFromCrm"
|
||||
);
|
||||
const {
|
||||
email,
|
||||
|
||||
@@ -41,8 +41,8 @@ import type {
|
||||
import type { zodRoutes } from "@calcom/app-store/routing-forms/zod";
|
||||
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import type { EventTypesByViewer } from "@calcom/features/eventtypes/lib/getEventTypesByViewer";
|
||||
import { areTheySiblingEntities } from "@calcom/lib/entityPermissionUtils.shared";
|
||||
import type { EventTypesByViewer } from "@calcom/lib/event-types/getEventTypesByViewer";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { buildEmptyQueryValue, raqbQueryValueUtils } from "@calcom/lib/raqb/raqbUtils";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useRedirectToLoginIfUnauthenticated } from "@calcom/features/auth/lib/hooks/useRedirectToLoginIfUnauthenticated";
|
||||
|
||||
export function RoutingFormAuthGuard({ children }: { children: React.ReactNode }) {
|
||||
useRedirectToLoginIfUnauthenticated();
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,7 +1,22 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import FormProvider from "./FormProvider";
|
||||
import { RoutingFormAuthGuard } from "./RoutingFormAuthGuard";
|
||||
|
||||
export default async function Layout({ children }: { children: ReactNode }) {
|
||||
return <FormProvider>{children}</FormProvider>;
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
params: { pages: string[] };
|
||||
}
|
||||
|
||||
export default async function Layout({ children, params }: LayoutProps) {
|
||||
const isPublic = params.pages?.[0] === "routing-link";
|
||||
if (isPublic) {
|
||||
return <FormProvider>{children}</FormProvider>;
|
||||
}
|
||||
|
||||
return (
|
||||
<RoutingFormAuthGuard>
|
||||
<FormProvider>{children}</FormProvider>
|
||||
</RoutingFormAuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ import { z } from "zod";
|
||||
|
||||
import { EventTypeWebWrapper } from "@calcom/atoms/event-types/wrappers/EventTypeWebWrapper";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
@@ -40,6 +44,95 @@ const getCachedEventType = unstable_cache(
|
||||
{ revalidate: 3600 } // Cache for 1 hour
|
||||
);
|
||||
|
||||
const getEventPermissions = async (userId: number, teamId: number | null) => {
|
||||
// Personal event - has all perms
|
||||
if (!teamId)
|
||||
return {
|
||||
eventTypes: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
workflows: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
};
|
||||
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) throw new Error("Membership not found");
|
||||
|
||||
const [eventTypePermissions, workflowPermissions] = await Promise.all([
|
||||
getResourcePermissions({
|
||||
userId,
|
||||
teamId,
|
||||
resource: Resource.EventType,
|
||||
userRole: membership.role,
|
||||
fallbackRoles: {
|
||||
read: {
|
||||
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
update: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
delete: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
create: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
}),
|
||||
getResourcePermissions({
|
||||
userId,
|
||||
teamId,
|
||||
resource: Resource.Workflow,
|
||||
userRole: membership.role,
|
||||
fallbackRoles: {
|
||||
read: {
|
||||
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
update: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
delete: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
create: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
eventTypes: {
|
||||
canRead: eventTypePermissions.canRead,
|
||||
canCreate: eventTypePermissions.canCreate,
|
||||
canUpdate: eventTypePermissions.canEdit,
|
||||
canDelete: eventTypePermissions.canDelete,
|
||||
},
|
||||
workflows: {
|
||||
canRead: workflowPermissions.canRead,
|
||||
canCreate: workflowPermissions.canCreate,
|
||||
canUpdate: workflowPermissions.canEdit,
|
||||
canDelete: workflowPermissions.canDelete,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const ServerPage = async ({ params }: PageProps) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
@@ -59,7 +152,10 @@ const ServerPage = async ({ params }: PageProps) => {
|
||||
throw new Error("This event type does not exist");
|
||||
}
|
||||
|
||||
return <EventTypeWebWrapper data={data} id={eventTypeId} />;
|
||||
// Fetch permissions for the event type's team
|
||||
const permissions = await getEventPermissions(session.user.id, data.eventType.teamId);
|
||||
|
||||
return <EventTypeWebWrapper data={data} id={eventTypeId} permissions={permissions} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { hasInsightsPermission } from "@calcom/features/insights/server/hasInsightsPermission";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
@@ -21,14 +20,5 @@ export async function checkInsightsPagePermission() {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const hasPermission = await hasInsightsPermission({
|
||||
userId: session.user.id,
|
||||
organizationId: session.user.org?.id,
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||
<div className="col-span-2 mb-6 font-semibold">
|
||||
<Price
|
||||
currency={paymentAppData.currency}
|
||||
price={paymentAppData.price}
|
||||
price={props.payment?.amount ?? paymentAppData.price}
|
||||
displayAlternateSymbol={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+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;
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ const Page = async () => {
|
||||
<SettingsHeader
|
||||
title={t("billing")}
|
||||
description={t("manage_billing_description")}
|
||||
borderInShellHeader={true}>
|
||||
borderInShellHeader={false}>
|
||||
<BillingView />
|
||||
</SettingsHeader>
|
||||
);
|
||||
|
||||
+6
-6
@@ -107,9 +107,9 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
|
||||
const { isAdvancedMode, permissions, color } = form.watch();
|
||||
|
||||
const filteredResources = useMemo(() => {
|
||||
const { filteredResources, scopedRegistry } = useMemo(() => {
|
||||
const scopedRegistry = getPermissionsForScope(scope);
|
||||
return Object.keys(scopedRegistry).filter((resource) =>
|
||||
const filteredResources = Object.keys(scopedRegistry).filter((resource) =>
|
||||
t(
|
||||
scopedRegistry[resource as Resource][CrudAction.All as keyof (typeof scopedRegistry)[Resource]]
|
||||
?.i18nKey || ""
|
||||
@@ -117,6 +117,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())
|
||||
);
|
||||
return { filteredResources, scopedRegistry };
|
||||
}, [searchQuery, t, scope]);
|
||||
|
||||
const createMutation = trpc.viewer.pbac.createRole.useMutation({
|
||||
@@ -212,7 +213,6 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
disabled={isSystemRole}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filteredResources.map((resource) => (
|
||||
<AdvancedPermissionGroup
|
||||
key={resource}
|
||||
@@ -222,7 +222,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
disabled={isSystemRole}
|
||||
scope={scope}
|
||||
/>
|
||||
))}
|
||||
))}{" "}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted rounded-xl p-1">
|
||||
@@ -237,7 +237,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-default border-subtle divide-subtle divide-y rounded-[10px] border">
|
||||
{Object.keys(getPermissionsForScope(scope)).map((resource) => (
|
||||
{Object.keys(scopedRegistry).map((resource) => (
|
||||
<SimplePermissionItem
|
||||
key={resource}
|
||||
resource={resource}
|
||||
@@ -247,7 +247,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
scope={scope}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>{" "}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+10
-4
@@ -1,7 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { Resource, Scope } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import {
|
||||
Scope,
|
||||
PERMISSION_REGISTRY,
|
||||
getPermissionsForScope,
|
||||
} from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
|
||||
@@ -21,11 +25,13 @@ export function SimplePermissionItem({
|
||||
permissions,
|
||||
onChange,
|
||||
disabled,
|
||||
scope,
|
||||
scope = Scope.Organization,
|
||||
}: SimplePermissionItemProps) {
|
||||
const { t } = useLocale();
|
||||
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions(scope);
|
||||
const scopedRegistry = getPermissionsForScope(scope);
|
||||
|
||||
const registry = scopedRegistry || PERMISSION_REGISTRY;
|
||||
const isAllResources = resource === "*";
|
||||
const options = isAllResources
|
||||
? [
|
||||
@@ -41,7 +47,7 @@ export function SimplePermissionItem({
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<span className="text-default text-sm font-medium leading-none">
|
||||
{t(PERMISSION_REGISTRY[resource as Resource]._resource?.i18nKey || resource)}
|
||||
{t(registry[resource as Resource]._resource?.i18nKey || resource)}
|
||||
</span>
|
||||
<ToggleGroup
|
||||
onValueChange={(val) => {
|
||||
|
||||
+17
-10
@@ -1,8 +1,5 @@
|
||||
import { CrudAction, Scope } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import {
|
||||
PERMISSION_REGISTRY,
|
||||
getPermissionsForScope,
|
||||
} from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getPermissionsForScope } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import {
|
||||
getTransitiveDependencies,
|
||||
getTransitiveDependents,
|
||||
@@ -52,7 +49,8 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission
|
||||
return permissions.includes("*.*") ? "all" : "none";
|
||||
}
|
||||
|
||||
const resourceConfig = PERMISSION_REGISTRY[resource as keyof typeof PERMISSION_REGISTRY];
|
||||
const scopedRegistry = getPermissionsForScope(scope);
|
||||
const resourceConfig = scopedRegistry[resource as keyof typeof scopedRegistry];
|
||||
if (!resourceConfig) return "none";
|
||||
|
||||
// Check if global all permissions (*.*) is present
|
||||
@@ -111,7 +109,19 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission
|
||||
allResourcePerms = Object.keys(resourceConfig)
|
||||
.filter((action) => !action.startsWith("_"))
|
||||
.map((action) => `${resource}.${action}`);
|
||||
|
||||
// Add the resource permissions
|
||||
newPermissions.push(...allResourcePerms);
|
||||
|
||||
// Add all transitive dependencies for each permission
|
||||
allResourcePerms.forEach((perm) => {
|
||||
const dependencies = getTransitiveDependencies(perm, scope);
|
||||
dependencies.forEach((dep) => {
|
||||
if (!newPermissions.includes(dep)) {
|
||||
newPermissions.push(dep);
|
||||
}
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -132,15 +142,12 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission
|
||||
// First, remove *.* since we're modifying individual permissions
|
||||
let newPermissions = currentPermissions.filter((p) => p !== "*.*");
|
||||
|
||||
// Parse the permission to get resource and action
|
||||
const [resource, action] = permission.split(".");
|
||||
|
||||
if (enabled) {
|
||||
// Add the requested permission
|
||||
newPermissions.push(permission);
|
||||
|
||||
// Add all transitive dependencies
|
||||
const dependencies = getTransitiveDependencies(permission);
|
||||
const dependencies = getTransitiveDependencies(permission, scope);
|
||||
dependencies.forEach((dependency) => {
|
||||
if (!newPermissions.includes(dependency)) {
|
||||
newPermissions.push(dependency);
|
||||
@@ -151,7 +158,7 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission
|
||||
newPermissions = newPermissions.filter((p) => p !== permission);
|
||||
|
||||
// Remove all transitive dependents
|
||||
const dependents = getTransitiveDependents(permission);
|
||||
const dependents = getTransitiveDependents(permission, scope);
|
||||
dependents.forEach((dependent) => {
|
||||
newPermissions = newPermissions.filter((p) => p !== dependent);
|
||||
});
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import { Resource, CrudAction, Scope } from "@calcom/features/pbac/domain/types/
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { RoleService } from "@calcom/features/pbac/services/role.service";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -19,13 +19,13 @@ import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
const WORKFLOW_TEMPLATES = {
|
||||
"wf-10": {
|
||||
name: "Cal AI No-show Follow-up Call",
|
||||
name: "Cal.ai No-show Follow-up Call",
|
||||
trigger: WorkflowTriggerEvents.BOOKING_NO_SHOW_UPDATED,
|
||||
time: 0,
|
||||
timeUnit: TimeUnit.MINUTE,
|
||||
},
|
||||
"wf-11": {
|
||||
name: "Cal AI 1-hour Meeting Reminder",
|
||||
name: "Cal.ai 1-hour Meeting Reminder",
|
||||
trigger: WorkflowTriggerEvents.BEFORE_EVENT,
|
||||
time: 1,
|
||||
timeUnit: TimeUnit.HOUR,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache";
|
||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/lib/CalendarManager";
|
||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/features/calendars/lib/CalendarManager";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import notEmpty from "@calcom/lib/notEmpty";
|
||||
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
|
||||
|
||||
@@ -17,7 +17,7 @@ import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import {
|
||||
getAllTranscriptsAccessLinkFromMeetingId,
|
||||
submitBatchProcessorTranscriptionJob,
|
||||
} from "@calcom/lib/videoClient";
|
||||
} from "@calcom/app-store/videoClient";
|
||||
import { generateVideoToken } from "@calcom/lib/videoTokens";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { getBooking } from "@calcom/web/lib/daily-webhook/getBooking";
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { describe, expect, test, vi, afterEach } from "vitest";
|
||||
|
||||
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/lib/videoClient";
|
||||
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/app-store/videoClient";
|
||||
import { verifyVideoToken } from "@calcom/lib/videoTokens";
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
vi.mock("@calcom/lib/videoClient", () => ({
|
||||
vi.mock("@calcom/app-store/videoClient", () => ({
|
||||
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/lib/videoClient";
|
||||
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/app-store/videoClient";
|
||||
import { verifyVideoToken } from "@calcom/lib/videoTokens";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
|
||||
@@ -7,9 +7,10 @@ import { useFieldArray, useFormContext } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { locationsResolver } from "@calcom/lib/event-types/utils/locationsResolver";
|
||||
import type { LocationObject } from "@calcom/app-store/locations";
|
||||
import { locationsResolver } from "@calcom/app-store/locations";
|
||||
import NoSSR from "@calcom/lib/components/NoSSR";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { LocationObject } from "@calcom/lib/location";
|
||||
import type { AppCategories } from "@calcom/prisma/enums";
|
||||
import type { EventTypeMetaDataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
@@ -188,7 +189,7 @@ const EventTypeGroup = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfigureStepCard: FC<ConfigureStepCardProps> = (props) => {
|
||||
const ConfigureStepCardContent: FC<ConfigureStepCardProps> = (props) => {
|
||||
const { loading, formPortalRef, handleSetUpLater } = props;
|
||||
const { t } = useLocale();
|
||||
const { control, watch } = useFormContext<TEventTypesForm>();
|
||||
@@ -212,6 +213,7 @@ export const ConfigureStepCard: FC<ConfigureStepCardProps> = (props) => {
|
||||
);
|
||||
|
||||
const [submit, setSubmit] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const allUpdated = updatedEventTypesStatus.every((item) => item.every((iitem) => iitem.updated));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -221,60 +223,73 @@ export const ConfigureStepCard: FC<ConfigureStepCardProps> = (props) => {
|
||||
}
|
||||
}, [submit, allUpdated, mainForSubmitRef]);
|
||||
|
||||
return (
|
||||
formPortalRef?.current &&
|
||||
createPortal(
|
||||
<div className="mt-8">
|
||||
{fields.map((group, groupIndex) => (
|
||||
<div key={group.fieldId}>
|
||||
{eventTypeGroups[groupIndex].eventTypes.some((eventType) => eventType.selected === true) && (
|
||||
<div className="mb-2 mt-4 flex items-center">
|
||||
<Avatar
|
||||
alt=""
|
||||
imageSrc={group.image} // if no image, use default avatar
|
||||
size="md"
|
||||
className="inline-flex justify-center"
|
||||
/>
|
||||
<p className="text-subtle block pl-2">{group.slug}</p>
|
||||
</div>
|
||||
)}
|
||||
<EventTypeGroup
|
||||
groupIndex={groupIndex}
|
||||
setUpdatedEventTypesStatus={setUpdatedEventTypesStatus}
|
||||
submitRefs={submitRefs}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button form="outer-event-type-form" type="submit" className="hidden" ref={mainForSubmitRef}>
|
||||
Save
|
||||
</button>
|
||||
<Button
|
||||
className="text-md mt-6 w-full justify-center"
|
||||
type="button"
|
||||
data-testid="configure-step-save"
|
||||
onClick={() => {
|
||||
submitRefs.current.forEach((ref) => ref?.click());
|
||||
setSubmit(true);
|
||||
}}
|
||||
loading={loading}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
<div className="flex w-full flex-row justify-center">
|
||||
<Button
|
||||
color="minimal"
|
||||
data-testid="set-up-later"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
handleSetUpLater();
|
||||
}}
|
||||
className="mt-8 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
|
||||
{t("set_up_later")}
|
||||
</Button>
|
||||
if (!formPortalRef?.current || !mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="mt-8">
|
||||
{fields.map((group, groupIndex) => (
|
||||
<div key={group.fieldId}>
|
||||
{eventTypeGroups[groupIndex].eventTypes.some((eventType) => eventType.selected === true) && (
|
||||
<div className="mb-2 mt-4 flex items-center">
|
||||
<Avatar
|
||||
alt=""
|
||||
imageSrc={group.image} // if no image, use default avatar
|
||||
size="md"
|
||||
className="inline-flex justify-center"
|
||||
/>
|
||||
<p className="text-subtle block pl-2">{group.slug}</p>
|
||||
</div>
|
||||
)}
|
||||
<EventTypeGroup
|
||||
groupIndex={groupIndex}
|
||||
setUpdatedEventTypesStatus={setUpdatedEventTypesStatus}
|
||||
submitRefs={submitRefs}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
formPortalRef?.current
|
||||
)
|
||||
))}
|
||||
<button form="outer-event-type-form" type="submit" className="hidden" ref={mainForSubmitRef}>
|
||||
Save
|
||||
</button>
|
||||
<Button
|
||||
className="text-md mt-6 w-full justify-center"
|
||||
type="button"
|
||||
data-testid="configure-step-save"
|
||||
onClick={() => {
|
||||
submitRefs.current.forEach((ref) => ref?.click());
|
||||
setSubmit(true);
|
||||
}}
|
||||
loading={loading}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
|
||||
<div className="flex w-full flex-row justify-center">
|
||||
<Button
|
||||
color="minimal"
|
||||
data-testid="set-up-later"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
handleSetUpLater();
|
||||
}}
|
||||
className="mt-8 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
|
||||
{t("set_up_later")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>,
|
||||
formPortalRef.current
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfigureStepCard: FC<ConfigureStepCardProps> = (props) => {
|
||||
return (
|
||||
<NoSSR>
|
||||
<ConfigureStepCardContent {...props} />
|
||||
</NoSSR>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, type FC } from "react";
|
||||
|
||||
import { EventTypeAppSettings } from "@calcom/app-store/_components/EventTypeAppSettingsInterface";
|
||||
import type { EventTypeAppsList } from "@calcom/app-store/utils";
|
||||
import useAppsData from "@calcom/lib/hooks/useAppsData";
|
||||
import useAppsData from "@calcom/features/apps/hooks/useAppsData";
|
||||
|
||||
import type { ConfigureStepCardProps } from "@components/apps/installation/ConfigureStepCard";
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
showToast(t("booking_confirmation_success"), "success");
|
||||
}
|
||||
utils.viewer.bookings.invalidate();
|
||||
utils.viewer.me.bookingUnconfirmedCount.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("booking_confirmation_failed"), "error");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import type { TeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import type { TeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
|
||||
import type { UserProfile } from "@calcom/types/UserProfile";
|
||||
import { UserAvatar } from "@calcom/ui/components/avatar";
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip";
|
||||
import bookingFormHandlers from "@calcom/app-store/routing-forms/appBookingFormHandler";
|
||||
import { ROUTING_FORM_RESPONSE_ID_QUERY_STRING } from "@calcom/app-store/routing-forms/lib/constants";
|
||||
import {
|
||||
ROUTING_FORM_RESPONSE_ID_QUERY_STRING,
|
||||
ROUTING_FORM_QUEUED_RESPONSE_ID_QUERY_STRING,
|
||||
} from "@calcom/app-store/routing-forms/lib/constants";
|
||||
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
|
||||
import { getTeamMemberEmailForResponseOrContactUsingUrlQuery } from "@calcom/lib/server/getTeamMemberEmailFromCrm";
|
||||
import { getTeamMemberEmailForResponseOrContactUsingUrlQuery } from "@calcom/features/ee/teams/lib/getTeamMemberEmailFromCrm";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
vi.mock("@calcom/app-store/routing-forms/appBookingFormHandler", () => ({
|
||||
@@ -52,24 +55,22 @@ function mockBookingFormHandler({
|
||||
);
|
||||
}
|
||||
|
||||
async function createRoutingFormWithResponse({
|
||||
formData,
|
||||
}: {
|
||||
formData: {
|
||||
routes: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
queryValue?: any;
|
||||
action: {
|
||||
eventTypeId: number;
|
||||
type: RouteActionType;
|
||||
value: string;
|
||||
};
|
||||
attributeRoutingConfig: {
|
||||
skipContactOwner: boolean;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
}) {
|
||||
type RoutingFormData = {
|
||||
routes: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
queryValue?: any;
|
||||
action: {
|
||||
eventTypeId: number;
|
||||
type: RouteActionType;
|
||||
value: string;
|
||||
};
|
||||
attributeRoutingConfig: {
|
||||
skipContactOwner: boolean;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
async function _createRoutingFormBase({ formData }: { formData: RoutingFormData }) {
|
||||
const user = await prismock.user.create({
|
||||
data: {
|
||||
name: "Routing Form User",
|
||||
@@ -100,6 +101,12 @@ async function createRoutingFormWithResponse({
|
||||
},
|
||||
});
|
||||
|
||||
return { form, routeId };
|
||||
}
|
||||
|
||||
async function createRoutingFormWithResponse({ formData }: { formData: RoutingFormData }) {
|
||||
const { form, routeId } = await _createRoutingFormBase({ formData });
|
||||
|
||||
const responseRecord = await prismock.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
chosenRouteId: routeId,
|
||||
@@ -115,6 +122,26 @@ async function createRoutingFormWithResponse({
|
||||
return { form, responseRecord };
|
||||
}
|
||||
|
||||
async function createRoutingFormWithQueuedResponse({ formData }: { formData: RoutingFormData }) {
|
||||
const { form, routeId } = await _createRoutingFormBase({ formData });
|
||||
|
||||
const queuedResponseId = v4();
|
||||
const queuedResponseRecord = await prismock.app_RoutingForms_QueuedFormResponse.create({
|
||||
data: {
|
||||
id: queuedResponseId,
|
||||
chosenRouteId: routeId,
|
||||
response: {},
|
||||
form: {
|
||||
connect: {
|
||||
id: form.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { form, queuedResponseRecord };
|
||||
}
|
||||
|
||||
async function createHostForEvent({
|
||||
eventTypeData,
|
||||
userData,
|
||||
@@ -254,166 +281,250 @@ describe("getTeamMemberEmailForResponseOrContactUsingUrlQuery", () => {
|
||||
});
|
||||
|
||||
describe("Booking form handler", () => {
|
||||
it("should return teamMember email through booking form handler when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided", async () => {
|
||||
const bookerEmail = "booker@example.com";
|
||||
const teamMemberEmail = "owner@example.com";
|
||||
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
|
||||
await createHostForEvent({
|
||||
eventTypeData: {
|
||||
id: mockEventData.id,
|
||||
},
|
||||
userData: {
|
||||
email: teamMemberEmail,
|
||||
},
|
||||
});
|
||||
describe("when responseId is provided", () => {
|
||||
it("should return teamMember email through booking form handler when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided", async () => {
|
||||
const bookerEmail = "booker@example.com";
|
||||
const teamMemberEmail = "owner@example.com";
|
||||
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
|
||||
await createHostForEvent({
|
||||
eventTypeData: {
|
||||
id: mockEventData.id,
|
||||
},
|
||||
userData: {
|
||||
email: teamMemberEmail,
|
||||
},
|
||||
});
|
||||
|
||||
const { responseRecord } = await createRoutingFormWithResponse({
|
||||
formData: {
|
||||
routes: [
|
||||
{
|
||||
action: {
|
||||
eventTypeId: 1,
|
||||
type: RouteActionType.EventTypeRedirectUrl,
|
||||
value: "/team/sales",
|
||||
const { responseRecord } = await createRoutingFormWithResponse({
|
||||
formData: {
|
||||
routes: [
|
||||
{
|
||||
action: {
|
||||
eventTypeId: 1,
|
||||
type: RouteActionType.EventTypeRedirectUrl,
|
||||
value: "/team/sales",
|
||||
},
|
||||
attributeRoutingConfig: { skipContactOwner: false },
|
||||
},
|
||||
attributeRoutingConfig: { skipContactOwner: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: {
|
||||
email: bookerEmail,
|
||||
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
|
||||
"cal.salesforce.xxx": "true",
|
||||
},
|
||||
eventData: mockEventData,
|
||||
});
|
||||
|
||||
expect(result.email).toBe(teamMemberEmail);
|
||||
});
|
||||
|
||||
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: {
|
||||
email: bookerEmail,
|
||||
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
|
||||
"cal.salesforce.xxx": "true",
|
||||
},
|
||||
eventData: mockEventData,
|
||||
it("should return null when skipContactOwner is true even when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided", async () => {
|
||||
const bookerEmail = "booker@example.com";
|
||||
const teamMemberEmail = "owner@example.com";
|
||||
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
|
||||
await createHostForEvent({
|
||||
eventTypeData: {
|
||||
id: mockEventData.id,
|
||||
},
|
||||
userData: {
|
||||
email: teamMemberEmail,
|
||||
},
|
||||
});
|
||||
|
||||
const { responseRecord } = await createRoutingFormWithResponse({
|
||||
formData: {
|
||||
routes: [
|
||||
{
|
||||
action: {
|
||||
eventTypeId: 1,
|
||||
type: RouteActionType.EventTypeRedirectUrl,
|
||||
value: "/team/sales",
|
||||
},
|
||||
attributeRoutingConfig: { skipContactOwner: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: {
|
||||
email: bookerEmail,
|
||||
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
|
||||
"cal.salesforce.xxx": "true",
|
||||
},
|
||||
eventData: mockEventData,
|
||||
});
|
||||
|
||||
// Because skipContactOwner is true, the booking form handler should return null
|
||||
expect(result.email).toBe(null);
|
||||
});
|
||||
|
||||
expect(result.email).toBe(teamMemberEmail);
|
||||
it("should return null when when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided but the returned email isn't an event member", async () => {
|
||||
const bookerEmail = "booker@example.com";
|
||||
const teamMemberEmail = "ownerNotInEventType@example.com";
|
||||
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
|
||||
const SOME_OTHER_EVENT_ID = 200;
|
||||
await createHostForEvent({
|
||||
eventTypeData: {
|
||||
id: SOME_OTHER_EVENT_ID,
|
||||
},
|
||||
userData: {
|
||||
email: teamMemberEmail,
|
||||
},
|
||||
});
|
||||
|
||||
const { responseRecord } = await createRoutingFormWithResponse({
|
||||
formData: {
|
||||
routes: [
|
||||
{
|
||||
action: {
|
||||
eventTypeId: 1,
|
||||
type: RouteActionType.EventTypeRedirectUrl,
|
||||
value: "/team/sales",
|
||||
},
|
||||
attributeRoutingConfig: { skipContactOwner: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: {
|
||||
email: bookerEmail,
|
||||
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
|
||||
"cal.salesforce.xxx": "true",
|
||||
},
|
||||
eventData: mockEventData,
|
||||
});
|
||||
|
||||
expect(result.email).toBe(null);
|
||||
});
|
||||
|
||||
it("should return null when cal.routingFormResponseId is provided but cal.salesforce.xxxx is not", async () => {
|
||||
const bookerEmail = "booker@example.com";
|
||||
const teamMemberEmail = "owner@example.com";
|
||||
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
|
||||
await createHostForEvent({
|
||||
eventTypeData: {
|
||||
id: mockEventData.id,
|
||||
},
|
||||
userData: {
|
||||
email: teamMemberEmail,
|
||||
},
|
||||
});
|
||||
|
||||
const { responseRecord } = await createRoutingFormWithResponse({
|
||||
formData: {
|
||||
routes: [
|
||||
{
|
||||
action: {
|
||||
eventTypeId: 1,
|
||||
type: RouteActionType.EventTypeRedirectUrl,
|
||||
value: "/team/sales",
|
||||
},
|
||||
attributeRoutingConfig: { skipContactOwner: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: {
|
||||
email: bookerEmail,
|
||||
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
|
||||
"cal.somethingelse.xxx": "true",
|
||||
},
|
||||
eventData: mockEventData,
|
||||
});
|
||||
|
||||
expect(result.email).toBe(null);
|
||||
});
|
||||
});
|
||||
describe("when queuedFormResponseId is provided", () => {
|
||||
it("should return teamMember email through booking form handler when cal.queuedFormResponseId and cal.salesforce.xxxx=true is provided", async () => {
|
||||
const bookerEmail = "booker@example.com";
|
||||
const teamMemberEmail = "owner@example.com";
|
||||
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
|
||||
await createHostForEvent({
|
||||
eventTypeData: {
|
||||
id: mockEventData.id,
|
||||
},
|
||||
userData: {
|
||||
email: teamMemberEmail,
|
||||
},
|
||||
});
|
||||
|
||||
it("should return null when skipContactOwner is true even when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided", async () => {
|
||||
const bookerEmail = "booker@example.com";
|
||||
const teamMemberEmail = "owner@example.com";
|
||||
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
|
||||
await createHostForEvent({
|
||||
eventTypeData: {
|
||||
id: mockEventData.id,
|
||||
},
|
||||
userData: {
|
||||
email: teamMemberEmail,
|
||||
},
|
||||
});
|
||||
|
||||
const { responseRecord } = await createRoutingFormWithResponse({
|
||||
formData: {
|
||||
routes: [
|
||||
{
|
||||
action: {
|
||||
eventTypeId: 1,
|
||||
type: RouteActionType.EventTypeRedirectUrl,
|
||||
value: "/team/sales",
|
||||
const { queuedResponseRecord } = await createRoutingFormWithQueuedResponse({
|
||||
formData: {
|
||||
routes: [
|
||||
{
|
||||
action: {
|
||||
eventTypeId: 1,
|
||||
type: RouteActionType.EventTypeRedirectUrl,
|
||||
value: "/team/sales",
|
||||
},
|
||||
attributeRoutingConfig: { skipContactOwner: false },
|
||||
},
|
||||
attributeRoutingConfig: { skipContactOwner: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: {
|
||||
email: bookerEmail,
|
||||
[ROUTING_FORM_QUEUED_RESPONSE_ID_QUERY_STRING]: queuedResponseRecord.id,
|
||||
"cal.salesforce.xxx": "true",
|
||||
},
|
||||
eventData: mockEventData,
|
||||
});
|
||||
|
||||
expect(result.email).toBe(teamMemberEmail);
|
||||
});
|
||||
|
||||
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: {
|
||||
email: bookerEmail,
|
||||
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
|
||||
"cal.salesforce.xxx": "true",
|
||||
},
|
||||
eventData: mockEventData,
|
||||
});
|
||||
it("should return null when skipContactOwner is true even when cal.queuedFormResponseId and cal.salesforce.xxxx=true is provided", async () => {
|
||||
const bookerEmail = "booker@example.com";
|
||||
const teamMemberEmail = "owner@example.com";
|
||||
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
|
||||
await createHostForEvent({
|
||||
eventTypeData: {
|
||||
id: mockEventData.id,
|
||||
},
|
||||
userData: {
|
||||
email: teamMemberEmail,
|
||||
},
|
||||
});
|
||||
|
||||
// Because skipContactOwner is true, the booking form handler should return null
|
||||
expect(result.email).toBe(null);
|
||||
});
|
||||
|
||||
it("should return null when when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided but the returned email isn't an event member", async () => {
|
||||
const bookerEmail = "booker@example.com";
|
||||
const teamMemberEmail = "ownerNotInEventType@example.com";
|
||||
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
|
||||
const SOME_OTHER_EVENT_ID = 200;
|
||||
await createHostForEvent({
|
||||
eventTypeData: {
|
||||
id: SOME_OTHER_EVENT_ID,
|
||||
},
|
||||
userData: {
|
||||
email: teamMemberEmail,
|
||||
},
|
||||
});
|
||||
|
||||
const { responseRecord } = await createRoutingFormWithResponse({
|
||||
formData: {
|
||||
routes: [
|
||||
{
|
||||
action: {
|
||||
eventTypeId: 1,
|
||||
type: RouteActionType.EventTypeRedirectUrl,
|
||||
value: "/team/sales",
|
||||
const { queuedResponseRecord } = await createRoutingFormWithQueuedResponse({
|
||||
formData: {
|
||||
routes: [
|
||||
{
|
||||
action: {
|
||||
eventTypeId: 1,
|
||||
type: RouteActionType.EventTypeRedirectUrl,
|
||||
value: "/team/sales",
|
||||
},
|
||||
attributeRoutingConfig: { skipContactOwner: true },
|
||||
},
|
||||
attributeRoutingConfig: { skipContactOwner: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: {
|
||||
email: bookerEmail,
|
||||
[ROUTING_FORM_QUEUED_RESPONSE_ID_QUERY_STRING]: queuedResponseRecord.id,
|
||||
"cal.salesforce.xxx": "true",
|
||||
},
|
||||
eventData: mockEventData,
|
||||
});
|
||||
|
||||
// Because skipContactOwner is true, the booking form handler should return null
|
||||
expect(result.email).toBe(null);
|
||||
});
|
||||
|
||||
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: {
|
||||
email: bookerEmail,
|
||||
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
|
||||
"cal.salesforce.xxx": "true",
|
||||
},
|
||||
eventData: mockEventData,
|
||||
});
|
||||
|
||||
expect(result.email).toBe(null);
|
||||
});
|
||||
|
||||
it("should return null when cal.routingFormResponseId is provided but cal.salesforce.xxxx is not", async () => {
|
||||
const bookerEmail = "booker@example.com";
|
||||
const teamMemberEmail = "owner@example.com";
|
||||
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
|
||||
await createHostForEvent({
|
||||
eventTypeData: {
|
||||
id: mockEventData.id,
|
||||
},
|
||||
userData: {
|
||||
email: teamMemberEmail,
|
||||
},
|
||||
});
|
||||
|
||||
const { responseRecord } = await createRoutingFormWithResponse({
|
||||
formData: {
|
||||
routes: [
|
||||
{
|
||||
action: {
|
||||
eventTypeId: 1,
|
||||
type: RouteActionType.EventTypeRedirectUrl,
|
||||
value: "/team/sales",
|
||||
},
|
||||
attributeRoutingConfig: { skipContactOwner: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: {
|
||||
email: bookerEmail,
|
||||
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
|
||||
"cal.somethingelse.xxx": "true",
|
||||
},
|
||||
eventData: mockEventData,
|
||||
});
|
||||
|
||||
expect(result.email).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/lib/location";
|
||||
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;
|
||||
|
||||
@@ -4,14 +4,12 @@ import { URLSearchParams } from "url";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getFullName } from "@calcom/features/form-builder/utils";
|
||||
import { determineReschedulePreventionRedirect } from "@calcom/features/bookings/lib/reschedule/determineReschedulePreventionRedirect";
|
||||
import { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking";
|
||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||
import { getSafe } from "@calcom/lib/getSafe";
|
||||
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
|
||||
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
const querySchema = z.object({
|
||||
uid: z.string(),
|
||||
@@ -63,6 +61,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
allowReschedulingCancelledBookings: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
parentId: true,
|
||||
slug: true,
|
||||
},
|
||||
@@ -98,7 +97,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const eventType = booking.eventType ? booking.eventType : getDefaultEvent(dynamicEventSlugRef);
|
||||
|
||||
const userRepo = new UserRepository(prisma);
|
||||
@@ -112,59 +110,36 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
profileEnrichedBookingUser: enrichedBookingUser,
|
||||
});
|
||||
|
||||
const isForcedRescheduleForCancelledBooking = allowRescheduleForCancelledBooking;
|
||||
// If booking is already REJECTED, we can't reschedule this booking. Take the user to the booking page which would show it's correct status and other details.
|
||||
// If the booking is CANCELLED and allowRescheduleForCancelledBooking is false, we redirect the user to the original event link.
|
||||
// A booking that has been rescheduled to a new booking will also have a status of CANCELLED
|
||||
const isDisabledRescheduling = booking.eventType?.disableRescheduling;
|
||||
// This comes from query param and thus is considered forced
|
||||
const canBookThroughCancelledBookingRescheduleLink = booking.eventType?.allowReschedulingCancelledBookings;
|
||||
const isNonRescheduleableBooking =
|
||||
booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED;
|
||||
|
||||
if (isDisabledRescheduling) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: `/booking/${uid}`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isNonRescheduleableBooking && !isForcedRescheduleForCancelledBooking) {
|
||||
const canReschedule =
|
||||
booking.status === BookingStatus.CANCELLED && canBookThroughCancelledBookingRescheduleLink;
|
||||
return {
|
||||
redirect: {
|
||||
destination: canReschedule ? eventUrl : `/booking/${uid}`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!booking?.eventType && !booking?.dynamicEventSlugRef) {
|
||||
// TODO: Show something in UI to let user know that this booking is not rescheduleable
|
||||
return {
|
||||
notFound: true,
|
||||
} as {
|
||||
notFound: true;
|
||||
};
|
||||
} as const;
|
||||
}
|
||||
|
||||
const isBookingInPast = booking.endTime && new Date(booking.endTime) < new Date();
|
||||
if (isBookingInPast && !eventType.allowReschedulingPastBookings) {
|
||||
const destinationUrlSearchParams = new URLSearchParams();
|
||||
const responses = bookingSeat ? getSafe<string>(bookingSeat.data, ["responses"]) : booking.responses;
|
||||
const name = getFullName(getSafe<string | { firstName: string; lastName?: string }>(responses, ["name"]));
|
||||
const email = getSafe<string>(responses, ["email"]);
|
||||
// Check if reschedule should be prevented based on booking status and event type settings
|
||||
const reschedulePreventionRedirectUrl = determineReschedulePreventionRedirect({
|
||||
booking: {
|
||||
uid,
|
||||
status: booking.status,
|
||||
endTime: booking.endTime,
|
||||
responses: booking.responses,
|
||||
eventType: {
|
||||
disableRescheduling: !!eventType?.disableRescheduling,
|
||||
allowReschedulingPastBookings: eventType.allowReschedulingPastBookings,
|
||||
allowBookingFromCancelledBookingReschedule: !!eventType.allowReschedulingCancelledBookings,
|
||||
teamId: eventType.team?.id ?? null,
|
||||
},
|
||||
},
|
||||
eventUrl,
|
||||
forceRescheduleForCancelledBooking: allowRescheduleForCancelledBooking,
|
||||
bookingSeat,
|
||||
});
|
||||
|
||||
if (name) destinationUrlSearchParams.set("name", name);
|
||||
if (email) destinationUrlSearchParams.set("email", email);
|
||||
|
||||
const searchParamsString = destinationUrlSearchParams.toString();
|
||||
if (reschedulePreventionRedirectUrl) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: searchParamsString ? `${eventUrl}?${searchParamsString}` : eventUrl,
|
||||
destination: reschedulePreventionRedirectUrl,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -108,7 +108,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
if (!teamMemberEmail || !crmOwnerRecordType || !crmAppSlug) {
|
||||
const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import(
|
||||
"@calcom/lib/server/getTeamMemberEmailFromCrm"
|
||||
"@calcom/features/ee/teams/lib/getTeamMemberEmailFromCrm"
|
||||
);
|
||||
const {
|
||||
email,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -15,7 +15,7 @@ import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
|
||||
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { LocationObject } from "@calcom/lib/location";
|
||||
import type { LocationObject } from "@calcom/app-store/locations";
|
||||
import type { Team } from "@calcom/prisma/client";
|
||||
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-util
|
||||
import { orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
|
||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
|
||||
import { shouldHideBrandingForEvent } from "@calcom/lib/hideBranding";
|
||||
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { parseEventTypeColor } from "@calcom/lib/isEventTypeColor";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -954,6 +954,24 @@ export const EventTypesCTA = ({ userEventGroupsData }: Omit<Props, "user">) => {
|
||||
userEventGroupsData?.profiles
|
||||
?.filter((profile) => !profile.readOnly)
|
||||
?.filter((profile) => !profile.eventTypesLockedByOrg)
|
||||
?.filter((profile) => {
|
||||
// For personal profiles (teamId is null), always allow creation
|
||||
if (!profile.teamId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For team profiles, check if user has eventType.create permission
|
||||
// This will be populated by the server-side PBAC check
|
||||
// Fallback to role-based check (admin/owner) if canCreateEventTypes is not set
|
||||
if (profile.canCreateEventTypes !== undefined) {
|
||||
return profile.canCreateEventTypes;
|
||||
}
|
||||
|
||||
// Fallback: allow admin and owner roles
|
||||
return (
|
||||
profile.membershipRole === MembershipRole.ADMIN || profile.membershipRole === MembershipRole.OWNER
|
||||
);
|
||||
})
|
||||
?.map((profile) => {
|
||||
return {
|
||||
teamId: profile.teamId,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -77,21 +77,26 @@ const BillingView = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-8 text-sm sm:space-y-8">
|
||||
<CtaRow title={t("view_and_manage_billing_details")} description={t("view_and_edit_billing_details")}>
|
||||
<Button color="primary" href={billingHref} target="_blank" EndIcon="external-link">
|
||||
<div className="bg-muted border-muted mt-5 rounded-xl border p-1">
|
||||
<div className="bg-default border-muted flex rounded-[10px] border px-5 py-4">
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<h3 className="text-emphasis text-sm font-semibold leading-none">{t("manage_billing")}</h3>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{t("view_and_manage_billing_details")}
|
||||
</p>
|
||||
</div>
|
||||
<Button color="primary" href={billingHref} target="_blank" size="sm" EndIcon="external-link">
|
||||
{t("billing_portal")}
|
||||
</Button>
|
||||
</CtaRow>
|
||||
</div>
|
||||
<BillingCredits />
|
||||
<div className="border-subtle mt-6 space-y-6 rounded-lg border px-6 py-8 text-sm sm:space-y-8">
|
||||
<CtaRow title={t("need_anything_else")} description={t("further_billing_help")}>
|
||||
<Button color="secondary" onClick={onContactSupportClick}>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-5">
|
||||
<p className="text-subtle text-sm font-medium leading-tight">{t("need_help")}</p>
|
||||
<Button color="secondary" size="sm" onClick={onContactSupportClick}>
|
||||
{t("contact_support")}
|
||||
</Button>
|
||||
</CtaRow>
|
||||
</div>
|
||||
</div>
|
||||
<BillingCredits />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,17 +8,21 @@ import { useForm } from "react-hook-form";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { MemberInvitationModalWithoutMembers } from "@calcom/features/ee/teams/components/MemberInvitationModal";
|
||||
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
||||
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
|
||||
import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Select } from "@calcom/ui/components/form";
|
||||
import { TextField, Label, InputError } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { ProgressBar } from "@calcom/ui/components/progress-bar";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
|
||||
|
||||
@@ -29,6 +33,39 @@ type MonthOption = {
|
||||
endDate: string;
|
||||
};
|
||||
|
||||
type CreditRowProps = {
|
||||
label: string;
|
||||
value: number;
|
||||
isBold?: boolean;
|
||||
underline?: "dashed" | "solid";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CreditRow = ({ label, value, isBold = false, underline, className = "" }: CreditRowProps) => {
|
||||
const numberFormatter = new Intl.NumberFormat();
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
`my-1 flex justify-between`,
|
||||
underline === "dashed"
|
||||
? "border-subtle border-b border-dashed"
|
||||
: underline === "solid"
|
||||
? "border-subtle border-b border-solid"
|
||||
: "mt-1",
|
||||
className
|
||||
)}>
|
||||
<span
|
||||
className={classNames("text-sm", isBold ? "font-semibold" : "text-subtle font-medium leading-tight")}>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={classNames(`text-sm`, isBold ? "font-semibold" : "text-subtle font-medium leading-tight")}>
|
||||
{numberFormatter.format(value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getMonthOptions = (): MonthOption[] => {
|
||||
const options: MonthOption[] = [];
|
||||
const minDate = dayjs.utc("2025-05-01");
|
||||
@@ -60,6 +97,7 @@ export default function BillingCredits() {
|
||||
const monthOptions = useMemo(() => getMonthOptions(), []);
|
||||
const [selectedMonth, setSelectedMonth] = useState<MonthOption>(monthOptions[0]);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const {
|
||||
@@ -71,6 +109,7 @@ export default function BillingCredits() {
|
||||
|
||||
const params = useParamsWithFallback();
|
||||
const orgId = session.data?.user?.org?.id;
|
||||
const orgSlug = session.data?.user?.org?.slug;
|
||||
|
||||
const parsedTeamId = Number(params.id);
|
||||
const teamId: number | undefined = Number.isFinite(parsedTeamId)
|
||||
@@ -132,117 +171,153 @@ export default function BillingCredits() {
|
||||
buyCreditsMutation.mutate({ quantity: data.quantity, teamId });
|
||||
};
|
||||
|
||||
const teamCreditsPercentageUsed =
|
||||
creditsData.credits.totalMonthlyCredits > 0
|
||||
? (creditsData.credits.totalRemainingMonthlyCredits / creditsData.credits.totalMonthlyCredits) * 100
|
||||
: 0;
|
||||
const totalCredits = creditsData.credits.totalMonthlyCredits ?? 0;
|
||||
const totalUsed = creditsData.credits.totalCreditsUsedThisMonth ?? 0;
|
||||
|
||||
const teamCreditsPercentageUsed = totalCredits > 0 ? (totalUsed / totalCredits) * 100 : 0;
|
||||
const numberFormatter = new Intl.NumberFormat();
|
||||
|
||||
return (
|
||||
<div className="border-subtle mt-8 space-y-6 rounded-lg border px-6 py-6 pb-6 text-sm sm:space-y-8">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">{t("credits")}</h2>
|
||||
<ServerTrans
|
||||
t={t}
|
||||
i18nKey="view_and_manage_credits_description"
|
||||
components={[
|
||||
<Link
|
||||
key="Credit System"
|
||||
className="underline underline-offset-2"
|
||||
target="_blank"
|
||||
href="https://cal.com/help/billing-and-usage/messaging-credits">
|
||||
Learn more
|
||||
</Link>,
|
||||
]}
|
||||
/>
|
||||
<div className="-mx-6 mt-6">
|
||||
<hr className="border-subtle" />
|
||||
<>
|
||||
<div className="bg-muted border-muted mt-5 rounded-xl border p-1">
|
||||
<div className="flex flex-col gap-1 px-4 py-5">
|
||||
<h2 className="text-default text-base font-semibold leading-none">{t("credits")}</h2>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">{t("view_and_manage_credits")}</p>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
{creditsData.credits.totalMonthlyCredits > 0 ? (
|
||||
<div className="mb-4">
|
||||
<Label>{t("monthly_credits")}</Label>
|
||||
<ProgressBar
|
||||
color="green"
|
||||
percentageValue={teamCreditsPercentageUsed}
|
||||
label={`${Math.max(0, Math.round(teamCreditsPercentageUsed))}%`}
|
||||
/>
|
||||
<div className="text-subtle">
|
||||
<div>
|
||||
{t("total_credits", {
|
||||
totalCredits: creditsData.credits.totalMonthlyCredits,
|
||||
})}
|
||||
<div className="bg-default border-muted flex w-full rounded-[10px] border px-5 py-4">
|
||||
<div className="w-full">
|
||||
{totalCredits > 0 ? (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<CreditRow
|
||||
label={t("monthly_credits")}
|
||||
value={totalCredits}
|
||||
isBold={true}
|
||||
underline="dashed"
|
||||
/>
|
||||
<CreditRow label={t("credits_used")} value={totalUsed} underline="solid" />
|
||||
<CreditRow
|
||||
label={t("total_credits_remaining")}
|
||||
value={creditsData.credits.totalRemainingMonthlyCredits}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<ProgressBar color="green" percentageValue={100 - teamCreditsPercentageUsed} />
|
||||
</div>
|
||||
{/*750 credits per tip*/}
|
||||
<div className="mt-4 flex flex-1 items-center justify-between">
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{orgSlug ? t("credits_per_tip_org") : t("credits_per_tip_teams")}
|
||||
</p>
|
||||
<Button onClick={() => setShowMemberInvitationModal(true)} size="sm" color="secondary">
|
||||
{t("add_members_no_ellipsis")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{t("remaining_credits", {
|
||||
remainingCredits: creditsData.credits.totalRemainingMonthlyCredits,
|
||||
})}
|
||||
<div className="-mx-5 mt-5">
|
||||
<hr className="border-subtle" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{/*Auto Top-Up goes here when we have it*/}
|
||||
{/*<div className="-mx-5 mt-5">
|
||||
<hr className="border-subtle" />
|
||||
</div>*/}
|
||||
{/*Additional Credits*/}
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="mt-4 flex">
|
||||
<div className="-mb-1 mr-auto w-full">
|
||||
<div className="flex justify-between">
|
||||
<Label>{t("additional_credits")}</Label>
|
||||
<div className="mb-2 flex items-center gap-1">
|
||||
<p className="text-sm font-semibold leading-none">
|
||||
<span className="text-subtle font-medium">{t("current_balance")}</span>{" "}
|
||||
{numberFormatter.format(creditsData.credits.additionalCredits)}
|
||||
</p>
|
||||
<Tooltip content={t("view_additional_credits_expense_tip")}>
|
||||
<Icon name="info" className="text-emphasis h-3 w-3" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
{...register("quantity", {
|
||||
required: t("error_required_field"),
|
||||
min: { value: 50, message: t("minimum_of_credits_required") },
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
label=""
|
||||
containerClassName="w-full -mt-1"
|
||||
size="sm"
|
||||
onChange={(e) => setValue("quantity", Number(e.target.value))}
|
||||
min={50}
|
||||
addOnSuffix={<>{t("credits")}</>}
|
||||
/>
|
||||
<Button color="secondary" target="_blank" size="sm" type="submit" data-testid="buy-credits">
|
||||
{t("buy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<Label>
|
||||
{creditsData.credits.totalMonthlyCredits ? t("additional_credits") : t("available_credits")}
|
||||
</Label>
|
||||
<div className="mt-2 text-sm">{creditsData.credits.additionalCredits}</div>
|
||||
<div className="-mx-6 mb-6 mt-6">
|
||||
<hr className="border-subtle mb-3 mt-3" />
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex">
|
||||
<div className="-mb-1 mr-auto">
|
||||
<Label>{t("buy_additional_credits")}</Label>
|
||||
<div className="flex flex-col">
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
{...register("quantity", {
|
||||
required: t("error_required_field"),
|
||||
min: { value: 50, message: t("minimum_of_credits_required") },
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
label=""
|
||||
containerClassName="w-60"
|
||||
onChange={(e) => setValue("quantity", Number(e.target.value))}
|
||||
min={50}
|
||||
addOnSuffix={<>{t("credits")}</>}
|
||||
/>
|
||||
{errors.quantity && <InputError message={errors.quantity.message ?? t("invalid_input")} />}
|
||||
</div>
|
||||
</form>
|
||||
<div className="-mx-5 mt-5">
|
||||
<hr className="border-subtle" />
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<Button
|
||||
color="primary"
|
||||
target="_blank"
|
||||
EndIcon="external-link"
|
||||
type="submit"
|
||||
data-testid="buy-credits">
|
||||
{t("buy_credits")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="-mx-6 mb-6 mt-6">
|
||||
<hr className="border-subtle mb-3 mt-3" />
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="mr-auto">
|
||||
<Label className="mb-4">{t("download_expense_log")}</Label>
|
||||
<div className="mt-2 flex flex-col">
|
||||
<Select
|
||||
options={monthOptions}
|
||||
value={selectedMonth}
|
||||
onChange={(option) => option && setSelectedMonth(option)}
|
||||
/>
|
||||
{/*Download Expense Log*/}
|
||||
<div className="mt-4 flex">
|
||||
<div className="mr-auto w-full">
|
||||
<Label className="mb-4">{t("download_expense_log")}</Label>
|
||||
<div className="mr-2 mt-1">
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-full"
|
||||
innerClassNames={{
|
||||
control: "font-medium text-emphasis",
|
||||
}}
|
||||
options={monthOptions}
|
||||
value={selectedMonth}
|
||||
onChange={(option) => option && setSelectedMonth(option)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<Button onClick={handleDownload} loading={isDownloading} color="secondary" size="sm">
|
||||
{t("download")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<Button onClick={handleDownload} loading={isDownloading} StartIcon="file-down">
|
||||
{t("download")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/*Credit Worth Section*/}
|
||||
<div className="text-subtle px-5 py-4 text-sm font-medium leading-tight">
|
||||
<ServerTrans
|
||||
t={t}
|
||||
i18nKey="credit_worth_description"
|
||||
components={[
|
||||
<Link
|
||||
key="Credit System"
|
||||
className="underline underline-offset-2"
|
||||
target="_blank"
|
||||
href="https://cal.com/help/billing-and-usage/messaging-credits">
|
||||
Learn more
|
||||
</Link>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{teamId && (
|
||||
<MemberInvitationModalWithoutMembers
|
||||
teamId={teamId}
|
||||
showMemberInvitationModal={showMemberInvitationModal}
|
||||
hideInvitationModal={() => setShowMemberInvitationModal(false)}
|
||||
onSettingsOpen={() => {
|
||||
return;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,77 +3,76 @@ import { SkeletonText, SkeletonContainer, SkeletonButton } from "@calcom/ui/comp
|
||||
export function BillingCreditsSkeleton() {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="border-subtle mt-8 space-y-6 rounded-lg border px-6 py-6 text-sm sm:space-y-8">
|
||||
{/* Title and Description */}
|
||||
<div>
|
||||
<div>
|
||||
<SkeletonText className="mb-2 h-4 w-20" /> {/* Credits title */}
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonText className="h-4 w-72" /> {/* Description */}
|
||||
</div>
|
||||
|
||||
<div className="-mx-6 mt-6">
|
||||
<hr className="border-subtle" />
|
||||
</div>
|
||||
{/* Monthly credits */}
|
||||
<div className="mt-[20px]">
|
||||
<SkeletonText className="mb-4 h-4 w-32" /> {/* Monthly credits label */}
|
||||
<div className="h-2 w-full rounded-md bg-gray-200" /> {/* Progress bar */}
|
||||
<div className="text-subtle mt-2">
|
||||
<div>
|
||||
<SkeletonText className="h-4 w-40" /> {/* Total credits */}
|
||||
<div className="bg-muted border-muted mt-5 rounded-xl p-1">
|
||||
<div className="flex flex-col gap-1 px-4 py-5">
|
||||
<SkeletonText className="h-4 w-20" /> {/* Credits title */}
|
||||
<SkeletonText className="h-4 w-48" /> {/* View and manage credits description */}
|
||||
</div>
|
||||
<div className="bg-default border-muted flex w-full rounded-[10px] px-5 py-4">
|
||||
<div className="w-full">
|
||||
{/* Credits section */}
|
||||
<div className="mb-4">
|
||||
{/* Monthly credits row */}
|
||||
<div className="mt-1 flex justify-between">
|
||||
<SkeletonText className="h-4 w-32" />
|
||||
<SkeletonText className="h-4 w-20" />
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonText className="h-4 w-40" /> {/* Remaining credits */}
|
||||
{/* Additional credits row */}
|
||||
<div className="mt-1 flex justify-between">
|
||||
<SkeletonText className="h-4 w-36" />
|
||||
<SkeletonText className="h-4 w-20" />
|
||||
</div>
|
||||
{/* Remaining row */}
|
||||
<div className="mt-1 flex justify-between">
|
||||
<SkeletonText className="h-4 w-24" />
|
||||
<SkeletonText className="h-4 w-20" />
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="mt-4">
|
||||
<div className="h-2 w-full rounded-md bg-gray-200" />
|
||||
</div>
|
||||
{/* Credits per tip */}
|
||||
<div className="mt-4 flex flex-1 justify-between">
|
||||
<SkeletonText className="h-4 w-32" />
|
||||
<SkeletonButton className="h-8 w-24 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-5 mt-5">
|
||||
<hr className="border-subtle" />
|
||||
</div>
|
||||
{/* Additional Credits form */}
|
||||
<div className="mt-4 flex">
|
||||
<div className="-mb-1 mr-auto w-full">
|
||||
<SkeletonText className="mb-2 h-4 w-32" /> {/* Additional credits label */}
|
||||
<div className="flex w-full">
|
||||
<SkeletonText className="h-9 w-full rounded-md" /> {/* Input field */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 mt-auto">
|
||||
<SkeletonButton className="h-9 w-12 rounded-md" /> {/* Buy button */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-5 mt-5">
|
||||
<hr className="border-subtle" />
|
||||
</div>
|
||||
{/* Download Expense Log */}
|
||||
<div className="mt-4 flex">
|
||||
<div className="mr-auto w-full">
|
||||
<SkeletonText className="mb-4 h-4 w-40" /> {/* Download expense log label */}
|
||||
<div className="mr-2 mt-1">
|
||||
<SkeletonText className="h-9 w-full rounded-md" /> {/* Select dropdown */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<SkeletonButton className="h-9 w-20 rounded-md" /> {/* Download button */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Additional credits */}
|
||||
<div className="mt-4">
|
||||
<div>
|
||||
<SkeletonText className="mb-2 h-4 w-40" /> {/* Additional credits label */}
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonText className="h-4 w-24" /> {/* Additional credits value */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-6 mb-6 mt-4">
|
||||
<hr className="border-subtle mb-3 mt-3" />
|
||||
</div>
|
||||
{/* Buy credits form */}
|
||||
<div className="flex">
|
||||
<div className="mr-auto">
|
||||
<div>
|
||||
<SkeletonText className="mb-2 h-4 w-[268px]" /> {/* Buy credits label */}
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonText className="mt-2 h-8 w-[240px] rounded-md" /> {/* Input field */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto ">
|
||||
<SkeletonButton className="h-9 w-[120px] rounded-md" /> {/* Buy button */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-6 mb-6 mt-6">
|
||||
<hr className="border-subtle mb-3 mt-3" />
|
||||
</div>
|
||||
{/* Download expense log */}
|
||||
<div className="flex">
|
||||
<div className="mr-auto mt-1">
|
||||
<div>
|
||||
<SkeletonText className="mb-2 h-4 w-[156px]" /> {/* Label */}
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonText className="mt-2 h-8 w-[156px] rounded-md" /> {/* Select */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto ">
|
||||
<SkeletonButton className="h-9 w-[109px] rounded-md" /> {/* Download button */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Credit Worth Section */}
|
||||
<div className="text-subtle px-5 py-4">
|
||||
<SkeletonText className="h-4 w-full" />
|
||||
<SkeletonText className="mt-1 h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.6.20",
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("Admin Users Management", () => {
|
||||
test("should add a new user successfully", async ({ page, users }) => {
|
||||
const adminUser = await users.create({
|
||||
role: "ADMIN",
|
||||
});
|
||||
|
||||
await adminUser.apiLogin();
|
||||
|
||||
await page.goto("/settings/admin/users/add");
|
||||
|
||||
await page.waitForLoadState();
|
||||
|
||||
await page.fill('input[name="name"]', "Test User");
|
||||
await page.fill('input[name="username"]', "testuser");
|
||||
await page.fill('input[name="email"]', "testuser@example.com");
|
||||
await page.fill('input[name="bio"]', "Test user bio");
|
||||
|
||||
await page.locator('[data-testid="timezone-select"] div').first().click();
|
||||
|
||||
const responsePromise = page.waitForResponse((response) =>
|
||||
response.url().includes("/api/trpc/users/add")
|
||||
);
|
||||
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
const response = await responsePromise;
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
await page.waitForURL("/settings/admin/users");
|
||||
});
|
||||
|
||||
test("should edit an existing user successfully", async ({ page, users }) => {
|
||||
const adminUser = await users.create({
|
||||
role: "ADMIN",
|
||||
});
|
||||
|
||||
const userToEdit = await users.create({
|
||||
name: "Edit User",
|
||||
username: "edituser",
|
||||
});
|
||||
|
||||
await adminUser.apiLogin();
|
||||
|
||||
await page.goto(`/settings/admin/users/${userToEdit.id}/edit`);
|
||||
|
||||
await page.waitForLoadState();
|
||||
|
||||
await expect(page.locator('input[name="name"]')).toHaveValue("Edit User");
|
||||
|
||||
await page.fill('input[name="name"]', "Updated User");
|
||||
|
||||
const responsePromise = page.waitForResponse((response) =>
|
||||
response.url().includes("/api/trpc/users/update")
|
||||
);
|
||||
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
const response = await responsePromise;
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
await page.waitForURL("/settings/admin/users");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,6 +111,9 @@ export function createAppsFixture(page: Page) {
|
||||
},
|
||||
goToEventType: async (eventType: string) => {
|
||||
await page.getByRole("link", { name: eventType }).click();
|
||||
// fix the race condition
|
||||
await page.waitForSelector('[data-testid="event-title"]');
|
||||
await expect(page.getByTestId("vertical-tab-basics")).toHaveAttribute("aria-current", "page");
|
||||
},
|
||||
goToAppsTab: async () => {
|
||||
await page.getByTestId("vertical-tab-apps").click();
|
||||
|
||||
@@ -584,6 +584,10 @@ export const createUsersFixture = (
|
||||
get: () => store.users,
|
||||
logout: async () => {
|
||||
await page.goto("/auth/logout");
|
||||
const logoutBtn = page.getByTestId("logout-btn");
|
||||
await expect(logoutBtn).toHaveText("Go back to the login page");
|
||||
await page.reload();
|
||||
await expect(logoutBtn).toHaveText("Go back to the login page");
|
||||
},
|
||||
deleteAll: async () => {
|
||||
const ids = store.users.map((u) => u.id);
|
||||
@@ -680,6 +684,10 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||
},
|
||||
logout: async () => {
|
||||
await page.goto("/auth/logout");
|
||||
const logoutBtn = page.getByTestId("logout-btn");
|
||||
await expect(logoutBtn).toHaveText("Go back to the login page");
|
||||
await page.reload();
|
||||
await expect(logoutBtn).toHaveText("Go back to the login page");
|
||||
},
|
||||
getFirstTeamMembership: async () => {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { clearFilters, applySelectFilter } from "./filter-helpers";
|
||||
import { test } from "./lib/fixtures";
|
||||
import { createAllPermissionsArray, enablePBACForTeam } from "./lib/test-helpers/pbac";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
@@ -237,4 +240,113 @@ test.describe("Insights", async () => {
|
||||
// Wait for the download process to complete and save the downloaded file somewhere.
|
||||
await download.saveAs("./" + "test-insights.csv");
|
||||
});
|
||||
|
||||
test("should render all ChartCard components with expected titles", async ({ page, users }) => {
|
||||
const owner = await users.create();
|
||||
const member = await users.create();
|
||||
|
||||
await createTeamsAndMembership(owner.id, member.id);
|
||||
|
||||
await owner.apiLogin();
|
||||
|
||||
await page.goto("/insights");
|
||||
|
||||
const expectedChartTitles = [
|
||||
"Events",
|
||||
"Performance",
|
||||
"Event Trends",
|
||||
"Bookings by Hour",
|
||||
"Average Event Duration",
|
||||
"Most Bookings Scheduled",
|
||||
"Least Bookings Scheduled",
|
||||
"Most Bookings Completed",
|
||||
"Least Bookings Completed",
|
||||
"Most Cancelled",
|
||||
"Most No-Show",
|
||||
"Recent No-Show Guests",
|
||||
"Highest Rated",
|
||||
"Lowest Rated",
|
||||
"Recent Ratings",
|
||||
"Popular Events",
|
||||
];
|
||||
|
||||
for (const title of expectedChartTitles) {
|
||||
const chartCard = page
|
||||
.locator("[data-testid='panel-card'] h2")
|
||||
.filter({ hasText: new RegExp(`^${title}$`) });
|
||||
await expect(chartCard).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("should be able to access insights page with custom role lacking insights.read permission", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
const owner = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isUnpublished: true,
|
||||
isOrg: true,
|
||||
});
|
||||
|
||||
const userOne = await users.create();
|
||||
const userTwo = await users.create();
|
||||
|
||||
const { teamOne } = await createTeamsAndMembership(userOne.id, userTwo.id);
|
||||
|
||||
const orgMembership = await owner.getOrgMembership();
|
||||
const orgId = orgMembership.team.id;
|
||||
|
||||
await enablePBACForTeam(orgId);
|
||||
await enablePBACForTeam(teamOne.id);
|
||||
|
||||
const permissions = createAllPermissionsArray().filter(
|
||||
({ resource, action }) => !(resource === "insights" && action === "read")
|
||||
);
|
||||
|
||||
const customRole = await prisma.role.create({
|
||||
data: {
|
||||
id: `e2e_no_insights_${orgId}_${Date.now()}`,
|
||||
name: "E2E Role Without Insights",
|
||||
description: "E2E role for testing - has all permissions except insights.read",
|
||||
color: "#dc2626",
|
||||
teamId: orgId,
|
||||
type: "CUSTOM",
|
||||
permissions: {
|
||||
create: permissions,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.membership.update({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: userOne.id,
|
||||
teamId: teamOne.id,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
customRoleId: customRole.id,
|
||||
},
|
||||
});
|
||||
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const isPBACEnabled = await featuresRepository.checkIfTeamHasFeature(orgId, "pbac");
|
||||
expect(isPBACEnabled).toBe(true);
|
||||
|
||||
const permissionService = new PermissionCheckService();
|
||||
const hasPermission = await permissionService.checkPermission({
|
||||
userId: userOne.id,
|
||||
teamId: teamOne.id,
|
||||
permission: "insights.read",
|
||||
fallbackRoles: [],
|
||||
});
|
||||
expect(hasPermission).toBe(false);
|
||||
|
||||
await userOne.apiLogin();
|
||||
await page.goto("/insights");
|
||||
|
||||
// Verify the user can access the insights page
|
||||
await page.locator('[data-testid^="insights-filters-"]').waitFor();
|
||||
expect(page.url()).toContain("/insights");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
// Create array of all permissions from PERMISSION_REGISTRY
|
||||
export const createAllPermissionsArray = () => {
|
||||
const allPermissions: { resource: string; action: string }[] = [];
|
||||
|
||||
Object.entries(PERMISSION_REGISTRY).forEach(([resource, resourceConfig]) => {
|
||||
if (resource === "*") {
|
||||
return;
|
||||
}
|
||||
Object.entries(resourceConfig).forEach(([action, _details]) => {
|
||||
allPermissions.push({ resource, action });
|
||||
});
|
||||
});
|
||||
|
||||
return allPermissions;
|
||||
};
|
||||
|
||||
export const enablePBACForTeam = async (teamId: number) => {
|
||||
await prisma.teamFeatures.create({
|
||||
data: {
|
||||
featureId: "pbac",
|
||||
teamId: teamId,
|
||||
assignedBy: "e2e",
|
||||
assignedAt: new Date(),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -15,7 +15,7 @@ test.describe("Workflow Tab - Event Type", () => {
|
||||
test("Creating a new workflow", async ({ workflowPage }) => {
|
||||
const { createWorkflow, assertListCount } = workflowPage;
|
||||
|
||||
await createWorkflow({ name: "" });
|
||||
await createWorkflow({ name: "test workflow" });
|
||||
await assertListCount(3);
|
||||
});
|
||||
|
||||
|
||||
@@ -512,6 +512,10 @@
|
||||
<symbol class="lucide lucide-paperclip" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="paperclip">
|
||||
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" ></path>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-pause" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="pause">
|
||||
<rect x="14" y="4" width="4" height="16" rx="1" ></rect>
|
||||
<rect x="6" y="4" width="4" height="16" rx="1" ></rect>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-pencil" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="pencil">
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" ></path>
|
||||
<path d="m15 5 4 4" ></path>
|
||||
@@ -533,6 +537,9 @@
|
||||
<symbol class="lucide lucide-phone" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="phone">
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" ></path>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-play" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="play">
|
||||
<polygon points="6 3 20 12 6 21 6 3" ></polygon>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-plus" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="plus">
|
||||
<path d="M5 12h14" ></path>
|
||||
<path d="M12 5v14" ></path>
|
||||
|
||||
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
@@ -16,7 +16,8 @@
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display_override": ["minimal-ui"],
|
||||
"display": "standalone",
|
||||
"scope": "/",
|
||||
"start_url": "/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"verify_email_banner_body": "قم بتأكيد عنوان بريدك الإلكتروني لضمان أفضل تسليم للبريد الإلكتروني والتقويم",
|
||||
"verify_email_email_header": "تأكيد عنوان بريدك الإلكتروني",
|
||||
"verify_email_button": "تأكيد البريد الإلكتروني",
|
||||
"cal_ai_assistant": "مساعد Cal AI",
|
||||
"cal_ai_assistant": "المساعد",
|
||||
"send_cal_video_transcription_emails": "إرسال رسائل النسخ النصي لفيديو Cal",
|
||||
"description_send_cal_video_transcription_emails": "إرسال رسائل بريد إلكتروني تحتوي على النسخ النصي لفيديو Cal بعد انتهاء الاجتماع. (يتطلب خطة مدفوعة)",
|
||||
"verify_email_change_description": "لقد طلبت تغيير البريد الإلكتروني المستخدم لحسابك في {{appName}}. الرجاء النقر على الزر أدناه لتأكيد بريدك الإلكتروني الجديد.",
|
||||
@@ -819,6 +819,8 @@
|
||||
"workflow_validation_failed": "فشل التحقق من صحة سير العمل",
|
||||
"workflow_validation_empty_fields": "خطوة واحدة أو أكثر من خطوات سير العمل تحتوي على محتوى رسالة فارغ",
|
||||
"workflow_validation_unverified_contacts": "لم يتم التحقق من رقم هاتف واحد أو أكثر أو عناوين البريد الإلكتروني",
|
||||
"supercharge_your_workflows_with_cal_ai": "عزز سير عملك مع Cal.ai",
|
||||
"supercharge_your_workflows_with_cal_ai_description": "وكلاء ذكاء اصطناعي واقعيون يقومون بحجز الاجتماعات وإرسال التذكيرات ومتابعة عملائك.",
|
||||
"phone_number_imported_successfully": "تم استيراد رقم الهاتف وربطه بالوكيل بنجاح",
|
||||
"phone_number_deleted_successfully": "تم حذف رقم الهاتف بنجاح",
|
||||
"delete_phone_number_confirmation": "هل أنت متأكد أنك تريد حذف رقم الهاتف هذا؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
@@ -846,6 +848,7 @@
|
||||
"phone_number_subscription_cancelled_successfully": "تم إلغاء اشتراك رقم الهاتف بنجاح",
|
||||
"updating": "جاري التحديث",
|
||||
"round_robin": "الترتيب الدوري",
|
||||
"hi_how_are_you_doing": "مرحبًا، كيف حالك؟",
|
||||
"round_robin_description": "نقل الاجتماعات بشكل دوري بين أعضاء الفريق المتعددين.",
|
||||
"managed_event": "حدث تم إدارته",
|
||||
"username_placeholder": "اسم المستخدم",
|
||||
@@ -876,9 +879,9 @@
|
||||
"are_you_sure_you_want_to_delete_workflow_step": "هل أنت متأكد من أنك تريد حذف خطوة مسار العمل هذه؟",
|
||||
"do_you_still_want_to_unsubscribe": "هل ما زلت ترغب في إلغاء اشتراك رقم الهاتف من هذا الوكيل؟",
|
||||
"the_action_will_disconnect_phone_number": "سيؤدي هذا الإجراء إلى فصل رقم الهاتف عن الوكيل. لن يتمكن الوكيل من إجراء مكالمات حتى يتم توصيل رقم هاتف جديد.",
|
||||
"cal_ai_phone_numbers": "أرقام هواتف Cal AI",
|
||||
"cal_ai_phone_numbers": "أرقام الهواتف",
|
||||
"connect_phone_number": "توصيل رقم الهاتف",
|
||||
"cal_ai_phone_numbers_description": "إدارة أرقام هواتف Cal AI الخاصة بك",
|
||||
"cal_ai_phone_numbers_description": "إدارة أرقام هواتفك",
|
||||
"import_number": "استيراد رقم",
|
||||
"this_action_will_also": "سيقوم هذا الإجراء أيضًا بـ:",
|
||||
"import_phone_number": "استيراد رقم الهاتف",
|
||||
@@ -886,7 +889,7 @@
|
||||
"cancel_your_phone_number_subscription": "إلغاء اشتراك رقم هاتفك",
|
||||
"delete_associated_phone_number": "حذف رقم الهاتف المرتبط",
|
||||
"unauthorized_create_workflow": "أنت غير مصرح لك بإنشاء مسار العمل هذا",
|
||||
"import_phone_number_description": "استيراد رقم هاتف Twilio الخاص بك لاستخدامه مع هاتف Cal AI",
|
||||
"import_phone_number_description": "استيراد رقم هاتف Twilio الخاص بك لاستخدامه مع الهاتف",
|
||||
"phone_number_cost": "${{price}}/شهريًا",
|
||||
"buy_new_number": "شراء رقم جديد",
|
||||
"buy_number_cost_x_per_month": "تكلفة شراء رقم هاتف هي ${{priceInDollars}} شهريًا. سيتم محاسبتك شهريًا على كل رقم هاتف نشط.",
|
||||
@@ -897,6 +900,7 @@
|
||||
"yes_cancel_subscription": "نعم، إلغاء الاشتراك",
|
||||
"cancel_phone_number_subscription_confirmation": "هل أنت متأكد من رغبتك في إلغاء اشتراك رقم الهاتف هذا؟ لا يمكن التراجع عن هذا الإجراء وستفقد إمكانية الوصول إلى رقم الهاتف هذا.",
|
||||
"add_members": "إضافة أعضاء...",
|
||||
"add_members_no_ellipsis": "إضافة أعضاء",
|
||||
"no_assigned_members": "لا يوجد أعضاء معينين",
|
||||
"assigned_to": "تم التعيين إلى",
|
||||
"you_must_be_logged_in_to": "يجب تسجيل الدخول إلى {{url}}",
|
||||
@@ -1207,6 +1211,7 @@
|
||||
"categories": "الفئات",
|
||||
"pricing": "التسعير",
|
||||
"learn_more": "معرفة المزيد",
|
||||
"try_now": "جرب الآن",
|
||||
"privacy_policy": "سياسة الخصوصية",
|
||||
"terms_of_service": "شروط الخدمة",
|
||||
"remove": "إزالة",
|
||||
@@ -1440,6 +1445,7 @@
|
||||
"whatsapp_attendee_action": "إرسال رسالة عبر WhatsApp إلى أحد الحاضرين",
|
||||
"workflows": "سير العمل",
|
||||
"new_workflow_btn": "سير عمل جديد",
|
||||
"how_would_you_like_to_start": "كيف ترغب في البدء؟",
|
||||
"add_new_workflow": "إضافة سير عمل جديد",
|
||||
"reschedule_event_trigger": "متى يتم إعادة جدولة الحدث",
|
||||
"trigger": "مشغل",
|
||||
@@ -1716,6 +1722,8 @@
|
||||
"event_duration_info": "مدة الحدث",
|
||||
"event_time_info": "وقت بدء الحدث",
|
||||
"event_type_not_found": "لم يتم العثور على نوع الحدث",
|
||||
"number_to_call_variable": "رقم للاتصال",
|
||||
"number_to_call_info": "رقم هاتف المستخدم الذي تتصل به",
|
||||
"location_variable": "الموقع",
|
||||
"location_info": "موقع الحدث",
|
||||
"additional_notes_variable": "ملاحظات إضافية",
|
||||
@@ -1753,6 +1761,7 @@
|
||||
"team_url": "رابط الفريق",
|
||||
"team_members": "أعضاء الفريق",
|
||||
"more": "المزيد",
|
||||
"cal_ai_workflows": "سير عمل Cal.ai",
|
||||
"and_count_more": "و {{count}} أكثر",
|
||||
"more_page_footer": "نعتبر تطبيق الهاتف المحمول امتداداً لتطبيق الويب، لكن يرجى العودة لتطبيق الويب إذا كنت تقوم بأي إجراءات معقدة.",
|
||||
"workflow_example_1": "إرسال رسائل قصيرة للحضور للتذكير قبل 24 ساعة من بدء الحدث",
|
||||
@@ -1761,6 +1770,18 @@
|
||||
"workflow_example_4": "إرسال رسائل قصيرة للحضور للتذكير قبل ساعة من بدء الحدث",
|
||||
"workflow_example_5": "إرسال رسالة قصيرة مخصصة للحضور عند إعادة جدولة الحدث",
|
||||
"workflow_example_6": "إرسال بريد إلكتروني مخصص للمضيف عندما يتم حجز حدث جديد",
|
||||
"send_sms_reminder": "إرسال تذكير عبر الرسائل القصيرة",
|
||||
"send_sms_reminder_description": "قبل 24 ساعة من بدء الحدث",
|
||||
"follow_up_with_no_shows": "متابعة المتغيبين",
|
||||
"follow_up_with_no_shows_description": "بعد 30 دقيقة من انتهاء الحدث",
|
||||
"remind_attendees_to_bring_id": "تذكير الحاضرين بإحضار الهوية",
|
||||
"remind_attendees_to_bring_id_description": "قبل يوم واحد من بدء الحدث",
|
||||
"email_to_remind_booking": "تذكير بالبريد الإلكتروني",
|
||||
"email_to_remind_booking_description": "قبل ساعة واحدة من بدء الحدث",
|
||||
"custom_sms_reminder": "تذكير مخصص عبر الرسائل القصيرة",
|
||||
"custom_sms_reminder_description": "عند جدولة الحدث",
|
||||
"custom_email_reminder": "تذكير مخصص بالبريد الإلكتروني",
|
||||
"custom_email_reminder_description": "إعادة جدولة الحدث للمضيف",
|
||||
"count_managed_to_limit": "تضمين عدد الحجوزات من أنواع الفعاليات المُدارة",
|
||||
"welcome_to_cal_header": "مرحبًا بك في {{appName}}!",
|
||||
"edit_form_later_subtitle": "ستتمكن من تعديل هذا لاحقًا.",
|
||||
@@ -2184,6 +2205,8 @@
|
||||
"show_on_booking_page": "إظهار في صفحة الحجز",
|
||||
"visit_cancelled_booking": "يمكنك زيارة صفحة الحجز الملغى",
|
||||
"get_started_zapier_templates": "البدء في استخدام قوالب Zapier",
|
||||
"standard_templates": "القوالب القياسية",
|
||||
"cal_ai_templates": "قوالب Cal.ai",
|
||||
"team_is_unpublished": "لم يُنشر {{team}}",
|
||||
"org_is_unpublished_description": "رابط هذه المنظمة غير متاح حاليًا. يرجى الاتصال بمالك المنظمة أو طلب النشر منه.",
|
||||
"team_is_unpublished_description": "رابط هذا {{entity}} غير متاح حاليًا. يرجى الاتصال بمالك {{entity}} أو طلب نشره منه.",
|
||||
@@ -2341,7 +2364,6 @@
|
||||
"could_not_charge_card": "تعذر تنفيذ عملية الدفع من البطاقة.",
|
||||
"insights": "رؤى",
|
||||
"routing_forms": "نماذج التوجيه",
|
||||
"testing_workflow_info_message": "عند اختبار سير العمل هذا، يجب أن تدرك أنه لا يمكن جدولة رسائل البريد الإلكتروني والرسائل القصيرة إلا قبل ساعة واحدة على الأقل",
|
||||
"insights_no_data_found_for_filter": "لم يتم العثور على بيانات لعامل التصفية المحدد أو التواريخ المحددة.",
|
||||
"acknowledge_booking_no_show_fee": "أقر بأنه إذا لم أحضر هذا الحدث فسيتم سحب رسوم عدم حضور من بطاقتي تبلغ {{amount, currency}}.",
|
||||
"days": "أيام",
|
||||
@@ -2464,7 +2486,15 @@
|
||||
"insights_team_filter": "الفريق: {{teamName}}",
|
||||
"insights_user_filter": "المستخدم: {{userName}}",
|
||||
"insights_subtitle": "عرض Insights الحجز عبر أحداثك",
|
||||
"call_history": "سجل المكالمات",
|
||||
"call_history_subtitle": "عرض سجل المكالمات عبر مكالمات Cal.ai الخاصة بك",
|
||||
"location_options": "خيارات الموقع {{locationCount}}",
|
||||
"channel_type": "نوع القناة",
|
||||
"end_reason": "سبب الإنهاء",
|
||||
"session_status": "حالة الجلسة",
|
||||
"user_sentiment": "شعور المستخدم",
|
||||
"time_header": "الوقت",
|
||||
"from_header": "من",
|
||||
"custom_plan": "الخطة المخصصة",
|
||||
"email_embed": "البريد الإلكتروني المضمن",
|
||||
"add_times_to_your_email": "حدّد بعض الفترات المتاحة وأدرجها في رسالتك الإلكترونية",
|
||||
@@ -2752,6 +2782,8 @@
|
||||
"account_already_linked": "الحساب مرتبط بالفعل",
|
||||
"send_email": "إرسال بريد إلكتروني",
|
||||
"cal_ai_phone_call_action": "الاتصال بالحاضر باستخدام وكيل Cal.ai الصوتي",
|
||||
"call_to_confirm_booking": "اتصال لتأكيد الحجز",
|
||||
"cal_ai_phone_call_action_description": "قبل ساعتين من بدء الحدث",
|
||||
"cal_ai_agent_configuration": "إعدادات وكيل Cal.ai",
|
||||
"choose_at_least_one_event_type_test_call": "يرجى اختيار نوع حدث واحد على الأقل لإجراء مكالمة اختبارية.",
|
||||
"mark_as_no_show": "وضع علامة عدم الحضور",
|
||||
@@ -3203,9 +3235,15 @@
|
||||
"verify_email_change": "تأكيد تغيير البريد الإلكتروني",
|
||||
"buy_credits": "شراء رصيد",
|
||||
"credits": "الرصيد",
|
||||
"view_and_manage_credits": "عرض وإدارة الرصيد",
|
||||
"credits_used": "الرصيد المستخدم",
|
||||
"total_credits_remaining": "الرصيد المتبقي الإجمالي",
|
||||
"credits_per_tip_org": "تحصل على 1000 رصيد شهريًا لكل عضو في الفريق",
|
||||
"credits_per_tip_teams": "تحصل على 750 رصيد شهريًا لكل عضو في الفريق",
|
||||
"view_and_manage_credits": "عرض وإدارة الرصيد لإرسال رسائل SMS",
|
||||
"view_and_manage_credits_description": "عرض وإدارة الرصيد لإرسال رسائل SMS. قيمة الرصيد الواحد هي 1¢ (دولار أمريكي). <0>معرفة المزيد</0>",
|
||||
"credit_worth_description": "الرصيد الواحد يساوي 1 سنت (دولار أمريكي). <0>تعرف على المزيد</0>",
|
||||
"buy_additional_credits": "شراء رصيد إضافي (0.01 دولار لكل رصيد)",
|
||||
"view_additional_credits_expense_tip": "يمكنك عرض إنفاق الرصيد الإضافي في سجل النفقات الخاص بك",
|
||||
"overview": "نظرة عامة",
|
||||
"organization_slug_taken": "الاسم المختصر للمؤسسة مستخدم بالفعل",
|
||||
"you_cannot_create_an_organization_as_you_are_already_part_of_an_organization": "لا يمكنك إنشاء مؤسسة لأنك بالفعل جزء من مؤسسة",
|
||||
@@ -3386,10 +3424,13 @@
|
||||
"credit_limit_reached_message": "لقد نفد رصيد فريق Cal.com الخاص بك {{teamName}}. نتيجة لذلك، يتم الآن إرسال رسائل SMS عبر البريد الإلكتروني بدلاً من ذلك. لاستئناف إرسال SMS، يرجى شراء رصيد إضافي.",
|
||||
"credit_limit_reached_message_user": "لقد نفدت رصيد حسابك على Cal.com. نتيجة لذلك، يتم الآن إرسال رسائل SMS عبر البريد الإلكتروني بدلاً من ذلك. لاستئناف إرسال رسائل SMS، يرجى شراء رصيد إضافي.",
|
||||
"current_credit_balance": "الرصيد الحالي: {{balance}} رصيد",
|
||||
"current_balance": "الرصيد الحالي:",
|
||||
"notification_about_your_booking": "إشعار بخصوص حجزك",
|
||||
"monthly_credits": "الرصيد الشهري",
|
||||
"total_credits": "إجمالي الرصيد: {{totalCredits}}",
|
||||
"remaining_credits": "الرصيد المتبقي: {{remainingCredits}}",
|
||||
"remaining": "المتبقي",
|
||||
"total": "الإجمالي",
|
||||
"additional_credits": "رصيد إضافي",
|
||||
"routing_form_next_in_queue": "{{count}} التالي في قائمة الانتظار",
|
||||
"routing_form_select_members_to_email": "إرسال ردود البريد الإلكتروني إلى",
|
||||
@@ -3467,6 +3508,11 @@
|
||||
"pbac_desc_view_workflows": "عرض سير العمل الحالي وإعداداته",
|
||||
"pbac_desc_update_workflows": "تعديل وتغيير إعدادات سير العمل",
|
||||
"pbac_desc_delete_workflows": "إزالة سير العمل من النظام",
|
||||
"pbac_resource_webhook": "ويب هوك",
|
||||
"pbac_desc_create_webhooks": "إنشاء ويب هوكس",
|
||||
"pbac_desc_view_webhooks": "عرض ويب هوكس",
|
||||
"pbac_desc_update_webhooks": "تحديث الويب هوك",
|
||||
"pbac_desc_delete_webhooks": "حذف الويب هوك",
|
||||
"pbac_desc_manage_workflows": "وصول كامل لإدارة جميع سير العمل",
|
||||
"pbac_desc_create_event_types": "إنشاء أنواع الأحداث",
|
||||
"pbac_desc_view_event_types": "عرض أنواع الأحداث",
|
||||
@@ -3594,6 +3640,7 @@
|
||||
"webhook_trigger_event": "اسم حدث المشغل (مثل BOOKING_CREATED، BOOKING_CANCELLED)",
|
||||
"webhook_created_at": "وقت إنشاء الويب هوك",
|
||||
"webhook_type": "رابط نوع الحدث",
|
||||
"set_up_agent": "إعداد الوكيل",
|
||||
"webhook_title": "اسم نوع الحدث",
|
||||
"webhook_start_time": "وقت بدء الحدث",
|
||||
"webhook_end_time": "وقت انتهاء الحدث",
|
||||
@@ -3625,6 +3672,9 @@
|
||||
"visit": "زيارة",
|
||||
"location_custom_label_input_label": "تسمية مخصصة على صفحة الحجز",
|
||||
"meeting_link": "رابط الاجتماع",
|
||||
"session_outcome": "نتيجة الجلسة",
|
||||
"call_created": "تم إنشاء المكالمة",
|
||||
"voicemail": "البريد الصوتي",
|
||||
"my_bookings": "حجوزاتي",
|
||||
"phone": "الهاتف",
|
||||
"free": "مجاني",
|
||||
@@ -3632,6 +3682,8 @@
|
||||
"user_name": "اسم المستخدم",
|
||||
"expand_panel": "توسيع اللوحة",
|
||||
"collapse_panel": "طي اللوحة",
|
||||
"email_verification_required": "التحقق من البريد الإلكتروني مطلوب لهذا النوع من الأحداث",
|
||||
"invalid_verification_code": "تم تقديم رمز تحقق غير صالح",
|
||||
"you_have_one_team": "لديك فريق واحد",
|
||||
"consider_consolidating_one_team_org": "فكر في إنشاء مؤسسة لتوحيد الفواتير وأدوات الإدارة والتحليلات عبر فريقك.",
|
||||
"consider_consolidating_multi_team_org": "فكر في إنشاء مؤسسة لتوحيد الفواتير وأدوات الإدارة والتحليلات عبر فرقك.",
|
||||
@@ -3652,5 +3704,32 @@
|
||||
"before_scheduled_start_time": "قبل وقت البدء المحدد",
|
||||
"cancel_booking_acknowledge_no_show_fee": "أقر بأنه عند إلغاء الحجز قبل {{timeValue}} {{timeUnit}} من وقت البدء سيتم فرض رسوم التخلف عن الحضور بقيمة {{amount, currency}}",
|
||||
"contact_organizer": "إذا كانت لديك أي أسئلة، يرجى التواصل مع المنظم.",
|
||||
"booking_time_option": "وقت الحجز",
|
||||
"booking_time_option_description": "عندما يتم جدولة الحجز (من البداية إلى النهاية)",
|
||||
"created_at_option": "تم الإنشاء في",
|
||||
"created_at_option_description": "عندما تم إنشاء الحجز في الأصل",
|
||||
"call_details": "تفاصيل المكالمة",
|
||||
"call_id": "معرف المكالمة",
|
||||
"call_information": "معلومات المكالمة",
|
||||
"sentiment": "المشاعر",
|
||||
"disconnect_reason": "سبب الانقطاع",
|
||||
"call_summary": "ملخص المكالمة",
|
||||
"transcription": "النسخ",
|
||||
"event_details": "تفاصيل الحدث",
|
||||
"agent": "الوكيل",
|
||||
"no_transcript_available": "لا يوجد نص متاح",
|
||||
"testing_sms_workflow_info_message": "عند اختبار سير العمل هذا، انتبه إلى أنه يجب جدولة الرسائل النصية القصيرة قبل 15 دقيقة على الأقل",
|
||||
"start_from_scratch_title": "البدء من الصفر",
|
||||
"start_from_scratch_description": "إنشاء سير العمل الخاص بك من الصفر.",
|
||||
"cal_ai_template_title": "قالب Cal.ai",
|
||||
"cal_ai_template_description": "وكلاء الذكاء الاصطناعي الذين يحجزون الاجتماعات، ويرسلون التذكيرات، ويتابعون!",
|
||||
"voice": "الصوت",
|
||||
"select_voice": "اختر الصوت",
|
||||
"select_voice_for_agent": "اختر صوتًا لوكيلك",
|
||||
"choose_a_voice_for_your_agent": "اختر صوتًا لوكيلك",
|
||||
"trait": "سمة",
|
||||
"voice_id": "معرّف الصوت",
|
||||
"use_voice": "استخدم الصوت",
|
||||
"current_voice": "الصوت الحالي",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ أضف السلاسل الجديدة أعلاه هنا ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
"verify_email_banner_body": "Ən yaxşı e-poçt və təqvim çatdırılmasını təmin etmək üçün e-poçt ünvanınızı təsdiqləyin",
|
||||
"verify_email_email_header": "E-poçt ünvanınızı təsdiqləyin",
|
||||
"verify_email_button": "E-poçtu təsdiqlə",
|
||||
"cal_ai_assistant": "Cal AI Köməkçisi",
|
||||
"cal_ai_assistant": "Köməkçi",
|
||||
"send_cal_video_transcription_emails": "Cal Video transkript e-poçtlarını göndər",
|
||||
"description_send_cal_video_transcription_emails": "Görüş bitdikdən sonra Cal Video transkripsiyası olan e-poçtları göndərin. (Ödənişli plan tələb olunur)",
|
||||
"verify_email_change_description": "Son zamanlarda {{appName}} hesabınıza daxil olmaq üçün istifadə etdiyiniz e-poçt ünvanını dəyişdirmək istədiyinizi bildirdiniz. Yeni e-poçt ünvanınızı təsdiqləmək üçün aşağıdakı düyməni basın.",
|
||||
@@ -819,6 +819,8 @@
|
||||
"workflow_validation_failed": "İş axını doğrulaması uğursuz oldu",
|
||||
"workflow_validation_empty_fields": "Bir və ya daha çox iş axını addımında boş mesaj məzmunu var",
|
||||
"workflow_validation_unverified_contacts": "Bir və ya daha çox telefon nömrəsi və ya e-poçt ünvanı təsdiqlənməyib",
|
||||
"supercharge_your_workflows_with_cal_ai": "Cal.ai ilə İş Axınlarınızı Gücləndirin",
|
||||
"supercharge_your_workflows_with_cal_ai_description": "Görüşləri təyin edən, xatırlatmalar göndərən və müştərilərinizlə əlaqə saxlayan canlı AI agentləri.",
|
||||
"phone_number_imported_successfully": "Telefon nömrəsi uğurla idxal edildi və agentə bağlandı",
|
||||
"phone_number_deleted_successfully": "Telefon nömrəsi uğurla silindi",
|
||||
"delete_phone_number_confirmation": "Bu telefon nömrəsini silmək istədiyinizə əminsiniz? Bu əməliyyat geri qaytarıla bilməz.",
|
||||
@@ -846,6 +848,7 @@
|
||||
"phone_number_subscription_cancelled_successfully": "Telefon nömrəsi abunəliyi uğurla ləğv edildi",
|
||||
"updating": "Yenilənir",
|
||||
"round_robin": "Round Robin",
|
||||
"hi_how_are_you_doing": "Salam, necəsən?",
|
||||
"round_robin_description": "Görüşləri bir neçə komanda üzvü arasında dövr edin.",
|
||||
"managed_event": "İdarə olunan Tədbir",
|
||||
"username_placeholder": "istifadəçi adı",
|
||||
@@ -876,9 +879,9 @@
|
||||
"are_you_sure_you_want_to_delete_workflow_step": "Bu iş axını addımını silmək istədiyinizə əminsiniz?",
|
||||
"do_you_still_want_to_unsubscribe": "Bu agentdən telefon nömrəsinin abunəliyini ləğv etmək istəyirsiniz?",
|
||||
"the_action_will_disconnect_phone_number": "Bu əməliyyat telefon nömrəsini agentdən ayıracaq. Yeni telefon nömrəsi qoşulana qədər agent zəng edə bilməyəcək.",
|
||||
"cal_ai_phone_numbers": "Cal AI Telefon Nömrələri",
|
||||
"cal_ai_phone_numbers": "Telefon Nömrələri",
|
||||
"connect_phone_number": "Telefon Nömrəsini Qoş",
|
||||
"cal_ai_phone_numbers_description": "Cal AI Telefon Nömrələrinizi idarə edin",
|
||||
"cal_ai_phone_numbers_description": "Telefon Nömrələrinizi İdarə edin",
|
||||
"import_number": "Nömrəni İdxal et",
|
||||
"this_action_will_also": "Bu əməliyyat həmçinin:",
|
||||
"import_phone_number": "Telefon Nömrəsini İdxal et",
|
||||
@@ -886,7 +889,7 @@
|
||||
"cancel_your_phone_number_subscription": "Telefon nömrəsi abunəliyinizi ləğv edin",
|
||||
"delete_associated_phone_number": "Əlaqəli telefon nömrəsini silin",
|
||||
"unauthorized_create_workflow": "Bu iş axınını yaratmaq üçün səlahiyyətiniz yoxdur",
|
||||
"import_phone_number_description": "Cal AI Phone ilə istifadə etmək üçün Twilio telefon nömrənizi idxal edin",
|
||||
"import_phone_number_description": "Telefon ilə istifadə etmək üçün Twilio telefon nömrənizi idxal edin",
|
||||
"phone_number_cost": "${{price}}/ay",
|
||||
"buy_new_number": "Yeni Nömrə Alın",
|
||||
"buy_number_cost_x_per_month": "Telefon nömrəsi almaq ayda ${{priceInDollars}} məbləğində başa gəlir. Hər aktiv telefon nömrəsi üçün aylıq ödəniş edəcəksiniz.",
|
||||
@@ -897,6 +900,7 @@
|
||||
"yes_cancel_subscription": "Bəli, Abunəliyi Ləğv Et",
|
||||
"cancel_phone_number_subscription_confirmation": "Bu telefon nömrəsi abunəliyini ləğv etmək istədiyinizə əminsiniz? Bu əməliyyat geri qaytarıla bilməz və bu telefon nömrəsinə girişinizi itirəcəksiniz.",
|
||||
"add_members": "Üzvlər əlavə et...",
|
||||
"add_members_no_ellipsis": "Üzvləri əlavə et",
|
||||
"no_assigned_members": "Təyin edilmiş üzvlər yoxdur",
|
||||
"assigned_to": "Təyin edilmiş",
|
||||
"you_must_be_logged_in_to": "{{url}}-ə daxil olmalısınız",
|
||||
@@ -1207,6 +1211,7 @@
|
||||
"categories": "Kateqoriyalar",
|
||||
"pricing": "Qiymətlər",
|
||||
"learn_more": "Daha çox öyrən",
|
||||
"try_now": "İndi cəhd edin",
|
||||
"privacy_policy": "Məxfilik Siyasəti",
|
||||
"terms_of_service": "Xidmət Şərtləri",
|
||||
"remove": "Sil",
|
||||
@@ -1440,6 +1445,7 @@
|
||||
"whatsapp_attendee_action": "iştirakçıya WhatsApp mesajı göndərin",
|
||||
"workflows": "İş axınları",
|
||||
"new_workflow_btn": "Yeni İş Axını",
|
||||
"how_would_you_like_to_start": "Necə başlamaq istəyirsiniz?",
|
||||
"add_new_workflow": "Yeni iş axını əlavə et",
|
||||
"reschedule_event_trigger": "hadisə yenidən planlaşdırıldıqda",
|
||||
"trigger": "Tətik",
|
||||
@@ -1716,6 +1722,8 @@
|
||||
"event_duration_info": "Tədbirin müddəti",
|
||||
"event_time_info": "Tədbirin başlama vaxtı",
|
||||
"event_type_not_found": "EventType tapılmadı",
|
||||
"number_to_call_variable": "Zəng ediləcək nömrə",
|
||||
"number_to_call_info": "Zəng etdiyiniz istifadəçinin telefon nömrəsi",
|
||||
"location_variable": "Məkan",
|
||||
"location_info": "Tədbirin yeri",
|
||||
"additional_notes_variable": "Əlavə qeydlər",
|
||||
@@ -1753,6 +1761,7 @@
|
||||
"team_url": "Komanda URL",
|
||||
"team_members": "Komanda üzvləri",
|
||||
"more": "Daha çox",
|
||||
"cal_ai_workflows": "Cal.ai İş Axınları",
|
||||
"and_count_more": "və {{count}} daha",
|
||||
"more_page_footer": "Mobil tətbiqi veb tətbiqinin bir uzantısı kimi görürük. Hər hansı mürəkkəb əməliyyatları yerinə yetirirsinizsə, lütfən, veb tətbiqinə qayıdın.",
|
||||
"workflow_example_1": "Tədbir başlamazdan 24 saat əvvəl iştirakçıya SMS xatırlatma göndərin",
|
||||
@@ -1761,6 +1770,18 @@
|
||||
"workflow_example_4": "Tədbir başlamazdan 1 saat əvvəl iştirakçıya e-poçt xatırlatma göndərin",
|
||||
"workflow_example_5": "Tədbir təxirə salındıqda ev sahibinə xüsusi e-poçt göndərin",
|
||||
"workflow_example_6": "Yeni tədbir sifariş edildikdə ev sahibinə xüsusi SMS göndərin",
|
||||
"send_sms_reminder": "SMS xatırlatması göndər",
|
||||
"send_sms_reminder_description": "Tədbir başlamazdan 24 saat əvvəl",
|
||||
"follow_up_with_no_shows": "Gəlməyənlərlə əlaqə saxla",
|
||||
"follow_up_with_no_shows_description": "Tədbir bitdikdən 30 dəqiqə sonra",
|
||||
"remind_attendees_to_bring_id": "İştirakçılara şəxsiyyət vəsiqəsi gətirməyi xatırlat",
|
||||
"remind_attendees_to_bring_id_description": "Tədbir başlamazdan 1 gün əvvəl",
|
||||
"email_to_remind_booking": "E-poçt Xatırlatması",
|
||||
"email_to_remind_booking_description": "Tədbir başlamazdan 1 saat əvvəl",
|
||||
"custom_sms_reminder": "Fərdi SMS Xatırlatması",
|
||||
"custom_sms_reminder_description": "Tədbir planlaşdırıldıqda",
|
||||
"custom_email_reminder": "Fərdi E-poçt Xatırlatması",
|
||||
"custom_email_reminder_description": "Tədbir ev sahibi üçün yenidən planlaşdırıldıqda",
|
||||
"count_managed_to_limit": "İdarə olunan tədbir növlərindən rezervasiya saylarını daxil edin",
|
||||
"welcome_to_cal_header": "{{appName}}-a xoş gəlmisiniz!",
|
||||
"edit_form_later_subtitle": "Bunu sonra redaktə edə biləcəksiniz.",
|
||||
@@ -2184,6 +2205,8 @@
|
||||
"show_on_booking_page": "Rezervasiya səhifəsində göstər",
|
||||
"visit_cancelled_booking": "Ləğv edilmiş rezervasiya səhifəsinə baxa bilərsiniz",
|
||||
"get_started_zapier_templates": "Zapier şablonları ilə başlayın",
|
||||
"standard_templates": "Standart Şablonlar",
|
||||
"cal_ai_templates": "Cal.ai Şablonları",
|
||||
"team_is_unpublished": "{{team}} yayımlanmayıb",
|
||||
"org_is_unpublished_description": "Bu təşkilat bağlantısı hazırda mövcud deyil. Zəhmət olmasa təşkilat sahibinə müraciət edin və ya onu yayımlamasını xahiş edin.",
|
||||
"team_is_unpublished_description": "Bu komanda bağlantısı hazırda mövcud deyil. Zəhmət olmasa komanda sahibinə müraciət edin və ya onu yayımlamasını xahiş edin.",
|
||||
@@ -2341,7 +2364,6 @@
|
||||
"could_not_charge_card": "Ödəniş üçün kartdan pul çıxıla bilmədi.",
|
||||
"insights": "Görüşlər",
|
||||
"routing_forms": "Marşrutlaşdırma Formları",
|
||||
"testing_workflow_info_message": "Bu iş axınını sınaqdan keçirərkən, e-poçtların və SMS-lərin ən azı 1 saat əvvəl planlaşdırıla biləcəyini unutmayın",
|
||||
"insights_no_data_found_for_filter": "Seçilmiş filtr və ya tarixlər üçün məlumat tapılmadı.",
|
||||
"acknowledge_booking_no_show_fee": "Bu tədbirə qatılmasam, kartıma {{amount, currency}} gəlməmə haqqı tətbiq olunacağını qəbul edirəm.",
|
||||
"days": "günlər",
|
||||
@@ -2464,7 +2486,15 @@
|
||||
"insights_team_filter": "Komanda: {{teamName}}",
|
||||
"insights_user_filter": "İstifadəçi: {{userName}}",
|
||||
"insights_subtitle": "Tədbirləriniz üzrə rezervasiya məlumatlarını görün",
|
||||
"call_history": "Zəng Tarixçəsi",
|
||||
"call_history_subtitle": "Cal.ai zəngləriniz üzrə zəng tarixçəsinə baxın",
|
||||
"location_options": "{{locationCount}} məkan seçimi",
|
||||
"channel_type": "Kanal Növü",
|
||||
"end_reason": "Bitmə Səbəbi",
|
||||
"session_status": "Sessiya Statusu",
|
||||
"user_sentiment": "İstifadəçi Münasibəti",
|
||||
"time_header": "Vaxt",
|
||||
"from_header": "Kimdən",
|
||||
"custom_plan": "Xüsusi Plan",
|
||||
"email_embed": "Email Əlavəsi",
|
||||
"add_times_to_your_email": "Mövcud vaxtları seçin və Email-ə əlavə edin",
|
||||
@@ -2752,6 +2782,8 @@
|
||||
"account_already_linked": "Hesab artıq əlaqələndirilib",
|
||||
"send_email": "E-poçt göndərin",
|
||||
"cal_ai_phone_call_action": "Cal.ai Səs Agenti vasitəsilə iştirakçıya zəng edin",
|
||||
"call_to_confirm_booking": "Rezervasiyanı təsdiqləmək üçün zəng edin",
|
||||
"cal_ai_phone_call_action_description": "Tədbir başlamazdan 2 saat əvvəl",
|
||||
"cal_ai_agent_configuration": "Cal.ai Agent Konfiqurasiyası",
|
||||
"choose_at_least_one_event_type_test_call": "Test zəngi etmək üçün ən azı bir tədbir növü seçin.",
|
||||
"mark_as_no_show": "Gəlməyən kimi işarələyin",
|
||||
@@ -3203,9 +3235,15 @@
|
||||
"verify_email_change": "E-poçt dəyişikliyini təsdiqlə",
|
||||
"buy_credits": "Kredit alın",
|
||||
"credits": "Kreditlər",
|
||||
"view_and_manage_credits": "Kreditləri görüntüləyin və idarə edin",
|
||||
"credits_used": "İstifadə edilmiş kreditlər",
|
||||
"total_credits_remaining": "Ümumi qalan",
|
||||
"credits_per_tip_org": "Hər ay, hər komanda üzvü üçün 1000 kredit əldə edirsiniz",
|
||||
"credits_per_tip_teams": "Hər ay, hər komanda üzvü üçün 750 kredit əldə edirsiniz",
|
||||
"view_and_manage_credits": "SMS mesajları göndərmək üçün kreditlərə baxın və idarə edin",
|
||||
"view_and_manage_credits_description": "SMS mesajları göndərmək üçün kreditləri görüntüləyin və idarə edin. Bir kredit 1¢ (USD) dəyərindədir. <0>Daha ətraflı</0>",
|
||||
"credit_worth_description": "Bir kredit 1¢ (USD) dəyərindədir. <0>Daha ətraflı öyrənin</0>",
|
||||
"buy_additional_credits": "Əlavə kreditlər alın (kredit başına $0.01)",
|
||||
"view_additional_credits_expense_tip": "Əlavə kredit xərclərini xərc jurnalınızda görə bilərsiniz",
|
||||
"overview": "Ümumi baxış",
|
||||
"organization_slug_taken": "Təşkilat slaqı artıq istifadə olunub",
|
||||
"you_cannot_create_an_organization_as_you_are_already_part_of_an_organization": "Siz artıq bir təşkilatın üzvü olduğunuz üçün yeni təşkilat yarada bilməzsiniz",
|
||||
@@ -3386,10 +3424,13 @@
|
||||
"credit_limit_reached_message": "Cal.com {{teamName}} komandanızın kreditləri bitdi. Nəticədə, SMS mesajları indi e-poçt vasitəsilə göndərilir. SMS göndərməyə davam etmək üçün, zəhmət olmasa əlavə kreditlər alın.",
|
||||
"credit_limit_reached_message_user": "Cal.com hesabınızın kreditləri bitib. Nəticə olaraq, SMS mesajları artıq e-poçt vasitəsilə göndərilir. SMS göndərməyə davam etmək üçün, zəhmət olmasa əlavə kreditlər alın.",
|
||||
"current_credit_balance": "Cari balans: {{balance}} kredit",
|
||||
"current_balance": "Cari balans:",
|
||||
"notification_about_your_booking": "Rezervasiyanız haqqında bildiriş",
|
||||
"monthly_credits": "Aylıq kreditlər",
|
||||
"total_credits": "Ümumi kreditlər: {{totalCredits}}",
|
||||
"remaining_credits": "Qalan kreditlər: {{remainingCredits}}",
|
||||
"remaining": "Qalan",
|
||||
"total": "Ümumi",
|
||||
"additional_credits": "Əlavə kreditlər",
|
||||
"routing_form_next_in_queue": "Növbədə {{count}} növbəti",
|
||||
"routing_form_select_members_to_email": "E-poçt cavablarını göndərmək üçün üzvləri seçin",
|
||||
@@ -3467,6 +3508,11 @@
|
||||
"pbac_desc_view_workflows": "Mövcud iş axınlarına və onların konfiqurasiyalarına baxış",
|
||||
"pbac_desc_update_workflows": "İş axını parametrlərini redaktə etmək və dəyişdirmək",
|
||||
"pbac_desc_delete_workflows": "İş axınlarını sistemdən silmək",
|
||||
"pbac_resource_webhook": "Webhook",
|
||||
"pbac_desc_create_webhooks": "Webhook yaradın",
|
||||
"pbac_desc_view_webhooks": "Webhook-lara baxın",
|
||||
"pbac_desc_update_webhooks": "Webhook-ləri yeniləyin",
|
||||
"pbac_desc_delete_webhooks": "Webhook-ləri silin",
|
||||
"pbac_desc_manage_workflows": "Bütün iş axınlarına tam idarəetmə girişi",
|
||||
"pbac_desc_create_event_types": "Tədbir növləri yaratmaq",
|
||||
"pbac_desc_view_event_types": "Tədbir növlərinə baxmaq",
|
||||
@@ -3594,6 +3640,7 @@
|
||||
"webhook_trigger_event": "Tətikləyici hadisənin adı (məs., BOOKING_CREATED, BOOKING_CANCELLED)",
|
||||
"webhook_created_at": "Webhook yaradılma vaxtı",
|
||||
"webhook_type": "Tədbir növünün slaqı",
|
||||
"set_up_agent": "Agent qurun",
|
||||
"webhook_title": "Tədbir növünün adı",
|
||||
"webhook_start_time": "Tədbirin başlama vaxtı",
|
||||
"webhook_end_time": "Tədbirin bitmə vaxtı",
|
||||
@@ -3625,6 +3672,9 @@
|
||||
"visit": "Ziyarət et",
|
||||
"location_custom_label_input_label": "Rezervasiya səhifəsində xüsusi etiket",
|
||||
"meeting_link": "Görüş linki",
|
||||
"session_outcome": "Sessiya nəticəsi",
|
||||
"call_created": "Zəng yaradıldı",
|
||||
"voicemail": "Səsli poçt",
|
||||
"my_bookings": "Mənim Rezervasiyalarım",
|
||||
"phone": "Telefon",
|
||||
"free": "Pulsuz",
|
||||
@@ -3632,6 +3682,8 @@
|
||||
"user_name": "İstifadəçi Adı",
|
||||
"expand_panel": "Paneli Genişlət",
|
||||
"collapse_panel": "Paneli Yığ",
|
||||
"email_verification_required": "Bu tədbir növü üçün e-poçt doğrulaması tələb olunur",
|
||||
"invalid_verification_code": "Yanlış doğrulama kodu təqdim edilib",
|
||||
"you_have_one_team": "Bir komandanız var",
|
||||
"consider_consolidating_one_team_org": "Komandanız üzrə ödənişləri, admin alətlərini və analitikanı birləşdirmək üçün təşkilat qurmağı nəzərdən keçirin.",
|
||||
"consider_consolidating_multi_team_org": "Komandalarınız üzrə ödənişləri, admin alətlərini və analitikanı birləşdirmək üçün təşkilat qurmağı nəzərdən keçirin.",
|
||||
@@ -3652,5 +3704,32 @@
|
||||
"before_scheduled_start_time": "Planlaşdırılmış başlama vaxtından əvvəl",
|
||||
"cancel_booking_acknowledge_no_show_fee": "Təsdiq edirəm ki, rezervasiyanı başlama vaxtından {{timeValue}} {{timeUnit}} ərzində ləğv etməklə mənə {{amount, currency}} məbləğində gəlməmə haqqı tutulacaq",
|
||||
"contact_organizer": "Hər hansı sualınız varsa, təşkilatçı ilə əlaqə saxlayın.",
|
||||
"booking_time_option": "Rezervasiya vaxtı",
|
||||
"booking_time_option_description": "Rezervasiyanın planlaşdırıldığı vaxt (başlanğıcdan sona)",
|
||||
"created_at_option": "Yaradılma tarixi",
|
||||
"created_at_option_description": "Rezervasiyanın ilkin yaradıldığı vaxt",
|
||||
"call_details": "Zəng detalları",
|
||||
"call_id": "Zəng ID",
|
||||
"call_information": "Zəng məlumatı",
|
||||
"sentiment": "Əhval-ruhiyyə",
|
||||
"disconnect_reason": "Bağlantının kəsilmə səbəbi",
|
||||
"call_summary": "Zəng xülasəsi",
|
||||
"transcription": "Transkripsiya",
|
||||
"event_details": "Tədbir detalları",
|
||||
"agent": "Agent",
|
||||
"no_transcript_available": "Transkript mövcud deyil",
|
||||
"testing_sms_workflow_info_message": "Bu iş axınını sınaqdan keçirərkən, SMS-lərin ən azı 15 dəqiqə əvvəlcədən planlaşdırılmalı olduğunu nəzərə alın",
|
||||
"start_from_scratch_title": "Sıfırdan başlayın",
|
||||
"start_from_scratch_description": "Öz iş axınınızı sıfırdan yaradın.",
|
||||
"cal_ai_template_title": "Cal.ai şablonu",
|
||||
"cal_ai_template_description": "Görüşləri təyin edən, xatırlatmalar göndərən və davam etdirən AI agentləri!",
|
||||
"voice": "Səs",
|
||||
"select_voice": "Səs seçin",
|
||||
"select_voice_for_agent": "Agentiniz üçün səs seçin",
|
||||
"choose_a_voice_for_your_agent": "Agentiniz üçün səs seçin",
|
||||
"trait": "Xüsusiyyət",
|
||||
"voice_id": "Səs ID",
|
||||
"use_voice": "Səsdən istifadə edin",
|
||||
"current_voice": "Cari səs",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Yeni sətirləri bura əlavə edin ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
"verify_email_banner_body": "Проверете имейл адреса си, за да гарантирате най-добрата доставка на имейли и календари",
|
||||
"verify_email_email_header": "Потвърдете вашия имейл адрес",
|
||||
"verify_email_button": "Потвърди имейл",
|
||||
"cal_ai_assistant": "Cal AI асистент",
|
||||
"cal_ai_assistant": "Асистент",
|
||||
"send_cal_video_transcription_emails": "Изпращане на имейли с транскрипция на Cal Video",
|
||||
"description_send_cal_video_transcription_emails": "Изпращане на имейли с транскрипцията на Cal Video след края на срещата. (Изисква платен план)",
|
||||
"verify_email_change_description": "Наскоро поискахте да промените имейл адреса, който използвате за вход във вашия {{appName}} акаунт. Моля, кликнете върху бутона по-долу, за да потвърдите новия си имейл адрес.",
|
||||
@@ -819,6 +819,8 @@
|
||||
"workflow_validation_failed": "Валидацията на работния процес е неуспешна",
|
||||
"workflow_validation_empty_fields": "Една или повече стъпки от работния процес имат празно съдържание на съобщението",
|
||||
"workflow_validation_unverified_contacts": "Един или повече телефонни номера или имейл адреси не са потвърдени",
|
||||
"supercharge_your_workflows_with_cal_ai": "Подсилете вашите работни процеси с Cal.ai",
|
||||
"supercharge_your_workflows_with_cal_ai_description": "Реалистични AI агенти, които резервират срещи, изпращат напомняния и проследяват вашите клиенти.",
|
||||
"phone_number_imported_successfully": "Телефонният номер е импортиран и свързан с агента успешно",
|
||||
"phone_number_deleted_successfully": "Телефонният номер е изтрит успешно",
|
||||
"delete_phone_number_confirmation": "Сигурни ли сте, че искате да изтриете този телефонен номер? Това действие не може да бъде отменено.",
|
||||
@@ -846,6 +848,7 @@
|
||||
"phone_number_subscription_cancelled_successfully": "Абонаментът за телефонен номер е отменен успешно",
|
||||
"updating": "Обновяване",
|
||||
"round_robin": "Round Robin",
|
||||
"hi_how_are_you_doing": "Здравейте, как сте?",
|
||||
"round_robin_description": "Разпределяйте срещи между множество членове на екипа.",
|
||||
"managed_event": "Управлявано събитие",
|
||||
"username_placeholder": "потребителско име",
|
||||
@@ -876,9 +879,9 @@
|
||||
"are_you_sure_you_want_to_delete_workflow_step": "Сигурни ли сте, че искате да изтриете тази стъпка от работния процес?",
|
||||
"do_you_still_want_to_unsubscribe": "Все още ли искате да прекратите абонамента за този телефонен номер от този агент?",
|
||||
"the_action_will_disconnect_phone_number": "Това действие ще прекъсне връзката на телефонния номер с агента. Агентът няма да може да осъществява обаждания, докато не бъде свързан нов телефонен номер.",
|
||||
"cal_ai_phone_numbers": "Cal AI телефонни номера",
|
||||
"cal_ai_phone_numbers": "Телефонни номера",
|
||||
"connect_phone_number": "Свързване на телефонен номер",
|
||||
"cal_ai_phone_numbers_description": "Управлявайте вашите Cal AI телефонни номера",
|
||||
"cal_ai_phone_numbers_description": "Управлявайте вашите телефонни номера",
|
||||
"import_number": "Импортиране на номер",
|
||||
"this_action_will_also": "Това действие също ще:",
|
||||
"import_phone_number": "Импортиране на телефонен номер",
|
||||
@@ -886,7 +889,7 @@
|
||||
"cancel_your_phone_number_subscription": "Отказване на абонамента за телефонен номер",
|
||||
"delete_associated_phone_number": "Изтриване на свързания телефонен номер",
|
||||
"unauthorized_create_workflow": "Нямате разрешение да създадете този работен процес",
|
||||
"import_phone_number_description": "Импортирайте вашия Twilio телефонен номер за използване с Cal AI Phone",
|
||||
"import_phone_number_description": "Импортирайте вашия Twilio телефонен номер за използване с Phone",
|
||||
"phone_number_cost": "${{price}}/месец",
|
||||
"buy_new_number": "Купете нов номер",
|
||||
"buy_number_cost_x_per_month": "Закупуването на телефонен номер струва ${{priceInDollars}} на месец. Ще бъдете таксувани месечно за всеки активен телефонен номер.",
|
||||
@@ -897,6 +900,7 @@
|
||||
"yes_cancel_subscription": "Да, отказвам абонамента",
|
||||
"cancel_phone_number_subscription_confirmation": "Сигурни ли сте, че искате да прекратите абонамента за този телефонен номер? Това действие не може да бъде отменено и ще загубите достъп до този телефонен номер.",
|
||||
"add_members": "Добави членове...",
|
||||
"add_members_no_ellipsis": "Добави членове",
|
||||
"no_assigned_members": "Няма назначени членове",
|
||||
"assigned_to": "Назначено на",
|
||||
"you_must_be_logged_in_to": "Трябва да сте влезли в профила си, за да {{url}}",
|
||||
@@ -1207,6 +1211,7 @@
|
||||
"categories": "Категории",
|
||||
"pricing": "Цени",
|
||||
"learn_more": "Научи повече",
|
||||
"try_now": "Пробвайте сега",
|
||||
"privacy_policy": "Политика за поверителност",
|
||||
"terms_of_service": "Общи условия",
|
||||
"remove": "Премахни",
|
||||
@@ -1440,6 +1445,7 @@
|
||||
"whatsapp_attendee_action": "изпращане на WhatsApp съобщение до участника",
|
||||
"workflows": "Работни процеси",
|
||||
"new_workflow_btn": "Нов работен процес",
|
||||
"how_would_you_like_to_start": "Как бихте искали да започнете?",
|
||||
"add_new_workflow": "Добавяне на нов работен процес",
|
||||
"reschedule_event_trigger": "когато събитието е пренасрочено",
|
||||
"trigger": "Тригер",
|
||||
@@ -1716,6 +1722,8 @@
|
||||
"event_duration_info": "Продължителността на събитието",
|
||||
"event_time_info": "Началният час на събитието",
|
||||
"event_type_not_found": "Типът събитие не е намерен",
|
||||
"number_to_call_variable": "Номер за обаждане",
|
||||
"number_to_call_info": "Телефонният номер на потребителя, на когото се обаждате",
|
||||
"location_variable": "Локация",
|
||||
"location_info": "Локацията на събитието",
|
||||
"additional_notes_variable": "Допълнителни бележки",
|
||||
@@ -1753,6 +1761,7 @@
|
||||
"team_url": "URL на екипа",
|
||||
"team_members": "Членове на екипа",
|
||||
"more": "Още",
|
||||
"cal_ai_workflows": "Cal.ai работни процеси",
|
||||
"and_count_more": "и още {{count}}",
|
||||
"more_page_footer": "Разглеждаме мобилното приложение като разширение на уеб приложението. Ако извършвате сложни действия, моля, върнете се към уеб приложението.",
|
||||
"workflow_example_1": "Изпрати SMS напомняне 24 часа преди началото на събитието до участника",
|
||||
@@ -1761,6 +1770,18 @@
|
||||
"workflow_example_4": "Изпрати имейл напомняне 1 час преди началото на събитията до участника",
|
||||
"workflow_example_5": "Изпрати персонализиран имейл до домакина, когато събитието е пренасрочено",
|
||||
"workflow_example_6": "Изпрати персонализиран SMS до домакина, когато е резервирано ново събитие",
|
||||
"send_sms_reminder": "Изпращане на SMS напомняне",
|
||||
"send_sms_reminder_description": "24 часа преди началото на събитието",
|
||||
"follow_up_with_no_shows": "Проследяване на неявилите се",
|
||||
"follow_up_with_no_shows_description": "30 минути след края на събитието",
|
||||
"remind_attendees_to_bring_id": "Напомняне на участниците да носят лична карта",
|
||||
"remind_attendees_to_bring_id_description": "1 ден преди началото на събитието",
|
||||
"email_to_remind_booking": "Имейл напомняне",
|
||||
"email_to_remind_booking_description": "1 час преди началото на събитието",
|
||||
"custom_sms_reminder": "Персонализирано SMS напомняне",
|
||||
"custom_sms_reminder_description": "Когато събитието е планирано",
|
||||
"custom_email_reminder": "Персонализирано имейл напомняне",
|
||||
"custom_email_reminder_description": "Събитието е пренасрочено за домакина",
|
||||
"count_managed_to_limit": "Включване на броя резервации от управляваните типове събития",
|
||||
"welcome_to_cal_header": "Добре дошли в {{appName}}!",
|
||||
"edit_form_later_subtitle": "Ще можете да редактирате това по-късно.",
|
||||
@@ -2184,6 +2205,8 @@
|
||||
"show_on_booking_page": "Покажи на страницата за резервации",
|
||||
"visit_cancelled_booking": "Можете да посетите страницата с отменената резервация",
|
||||
"get_started_zapier_templates": "Започнете със Zapier шаблони",
|
||||
"standard_templates": "Стандартни шаблони",
|
||||
"cal_ai_templates": "Cal.ai шаблони",
|
||||
"team_is_unpublished": "{{team}} не е публикуван",
|
||||
"org_is_unpublished_description": "Тази връзка към организацията в момента не е достъпна. Моля, свържете се със собственика на организацията или го помолете да я публикува.",
|
||||
"team_is_unpublished_description": "Тази връзка към екипа в момента не е достъпна. Моля, свържете се със собственика на екипа или го помолете да я публикува.",
|
||||
@@ -2341,7 +2364,6 @@
|
||||
"could_not_charge_card": "Не можа да се извърши плащане с картата.",
|
||||
"insights": "Анализи",
|
||||
"routing_forms": "Формуляри за маршрутизиране",
|
||||
"testing_workflow_info_message": "Когато тествате този работен процес, имайте предвид, че имейлите и SMS съобщенията могат да бъдат планирани само поне 1 час предварително",
|
||||
"insights_no_data_found_for_filter": "Не са намерени данни за избрания филтър или избраните дати.",
|
||||
"acknowledge_booking_no_show_fee": "Потвърждавам, че ако не присъствам на това събитие, ще бъде начислена такса за неявяване в размер на {{amount, currency}} на моята карта.",
|
||||
"days": "дни",
|
||||
@@ -2464,7 +2486,15 @@
|
||||
"insights_team_filter": "Екип: {{teamName}}",
|
||||
"insights_user_filter": "Потребител: {{userName}}",
|
||||
"insights_subtitle": "Преглед на статистики за резервации за всички ваши събития",
|
||||
"call_history": "История на обажданията",
|
||||
"call_history_subtitle": "Преглед на историята на обажданията през вашите Cal.ai разговори",
|
||||
"location_options": "{{locationCount}} опции за локация",
|
||||
"channel_type": "Тип канал",
|
||||
"end_reason": "Причина за край",
|
||||
"session_status": "Статус на сесията",
|
||||
"user_sentiment": "Настроение на потребителя",
|
||||
"time_header": "Време",
|
||||
"from_header": "От",
|
||||
"custom_plan": "Персонализиран план",
|
||||
"email_embed": "Вграждане в имейл",
|
||||
"add_times_to_your_email": "Изберете няколко свободни часа и ги вградете във вашия имейл",
|
||||
@@ -2752,6 +2782,8 @@
|
||||
"account_already_linked": "Профилът вече е свързан",
|
||||
"send_email": "Изпрати имейл",
|
||||
"cal_ai_phone_call_action": "Обаждане до участника чрез Cal.ai гласов агент",
|
||||
"call_to_confirm_booking": "Обаждане за потвърждаване на резервация",
|
||||
"cal_ai_phone_call_action_description": "2 часа преди началото на събитието",
|
||||
"cal_ai_agent_configuration": "Конфигурация на Cal.ai агент",
|
||||
"choose_at_least_one_event_type_test_call": "Моля, изберете поне един тип събитие, за да направите тестово обаждане.",
|
||||
"mark_as_no_show": "Маркирай като неявил се",
|
||||
@@ -3203,9 +3235,15 @@
|
||||
"verify_email_change": "Потвърди промяната на имейл",
|
||||
"buy_credits": "Купете кредити",
|
||||
"credits": "Кредити",
|
||||
"view_and_manage_credits": "Преглед и управление на кредити",
|
||||
"credits_used": "Използвани кредити",
|
||||
"total_credits_remaining": "Общо оставащи",
|
||||
"credits_per_tip_org": "Получавате 1000 кредита на месец за всеки член на екипа",
|
||||
"credits_per_tip_teams": "Получавате 750 кредита на месец за всеки член на екипа",
|
||||
"view_and_manage_credits": "Преглед и управление на кредити за изпращане на SMS съобщения",
|
||||
"view_and_manage_credits_description": "Преглед и управление на кредити за изпращане на SMS съобщения. Един кредит струва 1¢ (USD). <0>Научете повече</0>",
|
||||
"credit_worth_description": "Един кредит струва 1¢ (USD). <0>Научете повече</0>",
|
||||
"buy_additional_credits": "Купете допълнителни кредити ($0.01 на кредит)",
|
||||
"view_additional_credits_expense_tip": "Можете да видите допълнителните разходи за кредити в дневника на разходите си",
|
||||
"overview": "Общ преглед",
|
||||
"organization_slug_taken": "Слъгът на организацията вече е зает",
|
||||
"you_cannot_create_an_organization_as_you_are_already_part_of_an_organization": "Не можете да създадете организация, тъй като вече сте част от организация",
|
||||
@@ -3386,10 +3424,13 @@
|
||||
"credit_limit_reached_message": "Вашият Cal.com екип {{teamName}} е изчерпал кредитите си. В резултат на това SMS съобщенията сега се изпращат чрез имейл. За да възобновите изпращането на SMS, моля, закупете допълнителни кредити.",
|
||||
"credit_limit_reached_message_user": "Кредитите на вашия Cal.com акаунт са изчерпани. В резултат на това SMS съобщенията сега се изпращат чрез имейл. За да възобновите изпращането на SMS, моля, закупете допълнителни кредити.",
|
||||
"current_credit_balance": "Текущ баланс: {{balance}} кредита",
|
||||
"current_balance": "Текущ баланс:",
|
||||
"notification_about_your_booking": "Известие за вашата резервация",
|
||||
"monthly_credits": "Месечни кредити",
|
||||
"total_credits": "Общо кредити: {{totalCredits}}",
|
||||
"remaining_credits": "Оставащи кредити: {{remainingCredits}}",
|
||||
"remaining": "Оставащи",
|
||||
"total": "Общо",
|
||||
"additional_credits": "Допълнителни кредити",
|
||||
"routing_form_next_in_queue": "{{count}} следващи на опашката",
|
||||
"routing_form_select_members_to_email": "Изпращане на имейл отговори до",
|
||||
@@ -3467,6 +3508,11 @@
|
||||
"pbac_desc_view_workflows": "Преглед на съществуващите работни процеси и техните конфигурации",
|
||||
"pbac_desc_update_workflows": "Редактиране и модифициране на настройките на работните процеси",
|
||||
"pbac_desc_delete_workflows": "Премахване на работни процеси от системата",
|
||||
"pbac_resource_webhook": "Уебхук",
|
||||
"pbac_desc_create_webhooks": "Създаване на уебхуци",
|
||||
"pbac_desc_view_webhooks": "Преглед на уебхуци",
|
||||
"pbac_desc_update_webhooks": "Актуализиране на уебхукове",
|
||||
"pbac_desc_delete_webhooks": "Изтриване на уебхукове",
|
||||
"pbac_desc_manage_workflows": "Пълен достъп за управление на всички работни процеси",
|
||||
"pbac_desc_create_event_types": "Създаване на типове събития",
|
||||
"pbac_desc_view_event_types": "Преглед на типове събития",
|
||||
@@ -3594,6 +3640,7 @@
|
||||
"webhook_trigger_event": "Името на задействащото събитие (напр. BOOKING_CREATED, BOOKING_CANCELLED)",
|
||||
"webhook_created_at": "Времето на уебхука",
|
||||
"webhook_type": "Слъгът на типа събитие",
|
||||
"set_up_agent": "Настройка на агент",
|
||||
"webhook_title": "Името на типа събитие",
|
||||
"webhook_start_time": "Началното време на събитието",
|
||||
"webhook_end_time": "Крайното време на събитието",
|
||||
@@ -3625,6 +3672,9 @@
|
||||
"visit": "Посети",
|
||||
"location_custom_label_input_label": "Персонализиран етикет на страницата за резервация",
|
||||
"meeting_link": "Линк за среща",
|
||||
"session_outcome": "Резултат от сесията",
|
||||
"call_created": "Създадено обаждане",
|
||||
"voicemail": "Гласова поща",
|
||||
"my_bookings": "Моите резервации",
|
||||
"phone": "Телефон",
|
||||
"free": "Безплатно",
|
||||
@@ -3632,6 +3682,8 @@
|
||||
"user_name": "Име на потребителя",
|
||||
"expand_panel": "Разгъни панела",
|
||||
"collapse_panel": "Свий панела",
|
||||
"email_verification_required": "Изисква се имейл верификация за този тип събитие",
|
||||
"invalid_verification_code": "Предоставен е невалиден код за верификация",
|
||||
"you_have_one_team": "Имате един екип",
|
||||
"consider_consolidating_one_team_org": "Помислете за създаване на организация, за да обедините таксуването, административните инструменти и анализите за вашия екип.",
|
||||
"consider_consolidating_multi_team_org": "Помислете за създаване на организация, за да обедините таксуването, административните инструменти и анализите за вашите екипи.",
|
||||
@@ -3652,5 +3704,32 @@
|
||||
"before_scheduled_start_time": "Преди планираното начално време",
|
||||
"cancel_booking_acknowledge_no_show_fee": "Потвърждавам, че при отмяна на резервацията в рамките на {{timeValue}} {{timeUnit}} преди началния час ще ми бъде начислена такса за неявяване в размер на {{amount, currency}}",
|
||||
"contact_organizer": "Ако имате въпроси, моля, свържете се с организатора.",
|
||||
"booking_time_option": "Време на резервация",
|
||||
"booking_time_option_description": "Когато резервацията е планирана (от начало до край)",
|
||||
"created_at_option": "Създадено на",
|
||||
"created_at_option_description": "Когато резервацията е първоначално създадена",
|
||||
"call_details": "Детайли на обаждането",
|
||||
"call_id": "ID на обаждането",
|
||||
"call_information": "Информация за обаждането",
|
||||
"sentiment": "Настроение",
|
||||
"disconnect_reason": "Причина за прекъсване",
|
||||
"call_summary": "Обобщение на обаждането",
|
||||
"transcription": "Транскрипция",
|
||||
"event_details": "Детайли на събитието",
|
||||
"agent": "Агент",
|
||||
"no_transcript_available": "Няма наличен транскрипт",
|
||||
"testing_sms_workflow_info_message": "Когато тествате този работен процес, имайте предвид, че SMS трябва да бъдат планирани поне 15 минути предварително",
|
||||
"start_from_scratch_title": "Започнете от нулата",
|
||||
"start_from_scratch_description": "Създайте свой собствен работен процес от нулата.",
|
||||
"cal_ai_template_title": "Cal.ai шаблон",
|
||||
"cal_ai_template_description": "AI агенти, които резервират срещи, изпращат напомняния и проследяват!",
|
||||
"voice": "Глас",
|
||||
"select_voice": "Избери глас",
|
||||
"select_voice_for_agent": "Избери глас за твоя агент",
|
||||
"choose_a_voice_for_your_agent": "Избери глас за твоя агент",
|
||||
"trait": "Характеристика",
|
||||
"voice_id": "ID на глас",
|
||||
"use_voice": "Използвай глас",
|
||||
"current_voice": "Текущ глас",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Добавете новите си низове над този ред ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user