Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e481f2921 | ||
|
|
0e6203ac3c | ||
|
|
624d6d7001 | ||
|
|
ec03ab632e | ||
|
|
c81b8145b5 | ||
|
|
fa35cc5210 | ||
|
|
1eb6ff3e65 | ||
|
|
fc4ecf500a | ||
|
|
9d4522825b | ||
|
|
e91bf53d80 | ||
|
|
79b319c1dd | ||
|
|
9197a73b31 | ||
|
|
ec3656e646 | ||
|
|
ae4b807500 | ||
|
|
34a9872df1 | ||
|
|
91a480b83a | ||
|
|
db19bb3d51 | ||
|
|
9afdb1ad7e | ||
|
|
78c2a69389 | ||
|
|
5a483e75c9 | ||
|
|
bb9c4bb8f3 | ||
|
|
02fdc1361c | ||
|
|
573132198e | ||
|
|
9133bc5b16 | ||
|
|
94ae3a5a73 | ||
|
|
0757b00db7 | ||
|
|
3402f79912 | ||
|
|
56ef5d667c | ||
|
|
bb9d99c3e1 | ||
|
|
1aac14056e | ||
|
|
7af46cc7ab | ||
|
|
37cec89bf6 | ||
|
|
67843adab9 | ||
|
|
64297f027c | ||
|
|
9fe5ff7721 | ||
|
|
3076aca457 | ||
|
|
5a59bb86cd | ||
|
|
17ab088af8 | ||
|
|
b81a307bd9 | ||
|
|
b312955838 | ||
|
|
003aaaa18b | ||
|
|
3b19c064fd | ||
|
|
e26a57bf25 | ||
|
|
b5585b3974 | ||
|
|
947b05bf90 | ||
|
|
a0e5e03903 | ||
|
|
09ee39a3d8 | ||
|
|
243012e82f | ||
|
|
d96c807505 | ||
|
|
dac1069181 | ||
|
|
06d6c180d1 | ||
|
|
a2bee76da6 | ||
|
|
52ecea964d | ||
|
|
f9b294cfc2 | ||
|
|
e5f14c9316 | ||
|
|
bed76beb2f | ||
|
|
ff38d6c7db | ||
|
|
1dc20e3aec | ||
|
|
44a3a9eabb | ||
|
|
c0c52750b6 | ||
|
|
3bcaf0ec22 | ||
|
|
b881e025f6 | ||
|
|
35d140e0a7 | ||
|
|
5fd55cbfc3 | ||
|
|
18c546a171 | ||
|
|
b01ceb427d |
@@ -306,6 +306,224 @@ import { Button } from "@calcom/ui/components/button";
|
||||
- **Tests**: Same as source file + `.test.ts` or `.spec.ts`
|
||||
- **Avoid**: Dot-suffixes like `.service.ts`, `.repository.ts` (except for tests, types, specs)
|
||||
|
||||
## Repository Method Conventions
|
||||
|
||||
Repositories should follow consistent naming and design patterns to promote reusability and maintainability.
|
||||
|
||||
### Method Naming Rules
|
||||
|
||||
**1. Don't include the repository's entity name in method names**
|
||||
|
||||
Method names should be concise and avoid redundancy since the repository class name already indicates the entity type.
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Concise method names
|
||||
class BookingRepository {
|
||||
findById(id: string) { ... }
|
||||
findByUserId(userId: string) { ... }
|
||||
create(data: BookingCreateInput) { ... }
|
||||
delete(id: string) { ... }
|
||||
}
|
||||
|
||||
// ❌ Bad - Redundant entity name in methods
|
||||
class BookingRepository {
|
||||
findBookingById(id: string) { ... }
|
||||
findBookingByUserId(userId: string) { ... }
|
||||
createBooking(data: BookingCreateInput) { ... }
|
||||
deleteBooking(id: string) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**2. Use `include` or similar keywords for methods that fetch relational data**
|
||||
|
||||
When a method retrieves additional related entities, make this explicit in the method name using keywords like `include`, `with`, or `andRelations`.
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Clear indication of included relations
|
||||
class EventTypeRepository {
|
||||
findById(id: string) {
|
||||
return prisma.eventType.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
findByIdIncludeHosts(id: string) {
|
||||
return prisma.eventType.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
hosts: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
findByIdIncludeHostsAndSchedule(id: string) {
|
||||
return prisma.eventType.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
hosts: true,
|
||||
schedule: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Bad - Unclear what data is included
|
||||
class EventTypeRepository {
|
||||
findById(id: string) {
|
||||
return prisma.eventType.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
hosts: true,
|
||||
schedule: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
findByIdForReporting(id: string) {
|
||||
return prisma.eventType.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
hosts: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Keep methods generic and reusable - avoid use-case-specific names**
|
||||
|
||||
Repository methods should be general-purpose and describe what data they return, not how or where it's used. This promotes code reuse across different features.
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Generic, reusable methods
|
||||
class BookingRepository {
|
||||
findByUserIdIncludeAttendees(userId: string) {
|
||||
return prisma.booking.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
attendees: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
findByDateRangeIncludeEventType(startDate: Date, endDate: Date) {
|
||||
return prisma.booking.findMany({
|
||||
where: {
|
||||
startTime: { gte: startDate },
|
||||
endTime: { lte: endDate },
|
||||
},
|
||||
include: {
|
||||
eventType: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Bad - Use-case-specific method names
|
||||
class BookingRepository {
|
||||
findBookingsForReporting(userId: string) {
|
||||
return prisma.booking.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
attendees: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
findBookingsForDashboard(startDate: Date, endDate: Date) {
|
||||
return prisma.booking.findMany({
|
||||
where: {
|
||||
startTime: { gte: startDate },
|
||||
endTime: { lte: endDate },
|
||||
},
|
||||
include: {
|
||||
eventType: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**4. No business logic in repositories**
|
||||
|
||||
Repositories should only handle data access. Business logic, validations, and complex transformations belong in the Service layer.
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Repository only handles data access
|
||||
class BookingRepository {
|
||||
findByIdIncludeAttendees(id: string) {
|
||||
return prisma.booking.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
attendees: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateStatus(id: string, status: BookingStatus) {
|
||||
return prisma.booking.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class BookingService {
|
||||
async confirmBooking(bookingId: string) {
|
||||
const booking = await this.bookingRepository.findByIdIncludeAttendees(bookingId);
|
||||
|
||||
if (!booking) {
|
||||
throw new Error("Booking not found");
|
||||
}
|
||||
|
||||
if (booking.status !== "PENDING") {
|
||||
throw new Error("Only pending bookings can be confirmed");
|
||||
}
|
||||
|
||||
// Business logic: send confirmation emails
|
||||
await this.emailService.sendConfirmationToAttendees(booking.attendees);
|
||||
|
||||
// Update status through repository
|
||||
return this.bookingRepository.updateStatus(bookingId, "CONFIRMED");
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Bad - Business logic in repository
|
||||
class BookingRepository {
|
||||
async confirmBooking(bookingId: string) {
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: {
|
||||
attendees: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
throw new Error("Booking not found");
|
||||
}
|
||||
|
||||
if (booking.status !== "PENDING") {
|
||||
throw new Error("Only pending bookings can be confirmed");
|
||||
}
|
||||
|
||||
// ❌ Business logic shouldn't be here
|
||||
await sendEmailToAttendees(booking.attendees);
|
||||
|
||||
return prisma.booking.update({
|
||||
where: { id: bookingId },
|
||||
data: { status: "CONFIRMED" },
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Summary
|
||||
|
||||
- Method names should be concise: `findById` not `findBookingById`
|
||||
- Use `include`/`with` keywords when fetching relations: `findByIdIncludeHosts`
|
||||
- Keep methods generic and reusable: `findByUserIdIncludeAttendees` not `findBookingsForReporting`
|
||||
- No business logic in repositories - that belongs in Services
|
||||
|
||||
## When using Day.js
|
||||
|
||||
```typescript
|
||||
|
||||
+4
-5
@@ -10,7 +10,6 @@
|
||||
/apps/web/lib/handleOrgRedirect.ts @calcom/Foundation
|
||||
/apps/web/middleware.ts @calcom/Foundation
|
||||
/deploy/**/* @calcom/Foundation
|
||||
/infra/**/* @calcom/Foundation
|
||||
/packages/app-store/applecalendar/**/* @calcom/Foundation
|
||||
/packages/app-store/caldavcalendar/**/* @calcom/Foundation
|
||||
/packages/app-store/exchange2013calendar/**/* @calcom/Foundation
|
||||
@@ -25,10 +24,10 @@
|
||||
/packages/embeds/**/* @calcom/Foundation
|
||||
/packages/features/auth/lib/next-auth-options.ts @calcom/Foundation
|
||||
/packages/features/bookings/lib/**/* @calcom/Foundation
|
||||
/packages/lib/getAggregatedAvailability.ts @calcom/Foundation
|
||||
/packages/lib/getUserAvailability.ts @calcom/Foundation
|
||||
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
|
||||
/packages/lib/slots.ts @calcom/Foundation
|
||||
/packages/features/availability/lib/getAggregatedAvailability/getAggregatedAvailability.ts @calcom/Foundation
|
||||
/packages/features/availability/lib/getUserAvailability.ts @calcom/Foundation
|
||||
/packages/features/bookings/lib/getLuckyUser.ts @calcom/Foundation
|
||||
/packages/features/schedules/lib/slots.ts @calcom/Foundation
|
||||
/packages/platform/atoms/**/*WebWrapper.tsx @calcom/Consumer
|
||||
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
||||
/packages/prisma/**/* @calcom/Foundation
|
||||
|
||||
Vendored
+2
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"editor.formatOnSave": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
|
||||
Vendored
Vendored
Vendored
@@ -4,7 +4,7 @@ import type { NextApiResponse, NextApiRequest } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { handleAutoLock } from "@calcom/lib/autoLock";
|
||||
import { handleAutoLock } from "@calcom/features/ee/api-keys/lib/autoLock";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
|
||||
@@ -19,7 +19,7 @@ vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => ({
|
||||
checkRateLimitAndThrowError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/autoLock", () => ({
|
||||
vi.mock("@calcom/features/ee/api-keys/lib/autoLock", () => ({
|
||||
handleAutoLock: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import { handleAutoLock } from "@calcom/lib/autoLock";
|
||||
import { handleAutoLock } from "@calcom/features/ee/api-keys/lib/autoLock";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
import prismock from "../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
/**
|
||||
* Unit Tests for verifyApiKey middleware
|
||||
*
|
||||
* These tests verify the middleware logic without touching the database.
|
||||
* All dependencies (repositories, utilities) are mocked.
|
||||
*/
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ILicenseKeyService } from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||
import LicenseKeyService, { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
|
||||
import type { IDeploymentRepository } from "@calcom/lib/server/repository/deployment.interface";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { ApiKeyService } from "@calcom/lib/server/service/ApiKeyService";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { isAdminGuard } from "../utils/isAdmin";
|
||||
import { isLockedOrBlocked } from "../utils/isLockedOrBlocked";
|
||||
import { ScopeOfAdmin } from "../utils/scopeOfAdmin";
|
||||
import { verifyApiKey } from "./verifyApiKey";
|
||||
|
||||
vi.mock("@calcom/lib/server/service/ApiKeyService", () => ({
|
||||
ApiKeyService: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/repository/PrismaApiKeyRepository", () => ({
|
||||
PrismaApiKeyRepository: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../utils/isAdmin", () => ({
|
||||
isAdminGuard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../utils/isLockedOrBlocked", () => ({
|
||||
isLockedOrBlocked: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/crypto", () => ({
|
||||
symmetricDecrypt: vi.fn().mockReturnValue("mocked-decrypted-value"),
|
||||
symmetricEncrypt: vi.fn().mockReturnValue("mocked-encrypted-value"),
|
||||
@@ -32,17 +55,29 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
const mockDeploymentRepository: IDeploymentRepository = {
|
||||
getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"), // Mocked return value
|
||||
getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"),
|
||||
getSignatureToken: vi.fn().mockResolvedValue("mockSignatureToken"),
|
||||
};
|
||||
|
||||
describe("Verify API key", () => {
|
||||
describe("Verify API key - Unit Tests", () => {
|
||||
let service: ILicenseKeyService;
|
||||
let mockApiKeyService: ApiKeyService;
|
||||
|
||||
beforeEach(async () => {
|
||||
service = await LicenseKeyService.create(mockDeploymentRepository);
|
||||
|
||||
vi.spyOn(service, "checkLicense");
|
||||
|
||||
vi.spyOn(LicenseKeySingleton, "getInstance").mockResolvedValue(service as LicenseKeyService);
|
||||
|
||||
mockApiKeyService = {
|
||||
verifyKeyByHashedKey: vi.fn(),
|
||||
} as unknown as ApiKeyService;
|
||||
|
||||
vi.mocked(ApiKeyService).mockImplementation(() => mockApiKeyService);
|
||||
vi.mocked(PrismaApiKeyRepository).mockImplementation(() => ({} as unknown as PrismaApiKeyRepository));
|
||||
|
||||
vi.mocked(isAdminGuard).mockReset();
|
||||
vi.mocked(isLockedOrBlocked).mockReset();
|
||||
});
|
||||
|
||||
it("should throw an error if the api key is not valid", async () => {
|
||||
@@ -98,22 +133,25 @@ describe("Verify API key", () => {
|
||||
query: {
|
||||
apiKey: "cal_test_key",
|
||||
},
|
||||
prisma,
|
||||
});
|
||||
const hashedKey = hashAPIKey("test_key");
|
||||
await prismock.apiKey.create({
|
||||
data: {
|
||||
hashedKey,
|
||||
user: {
|
||||
create: {
|
||||
email: "admin@example.com",
|
||||
role: UserPermissionRole.ADMIN,
|
||||
locked: false,
|
||||
},
|
||||
},
|
||||
|
||||
vi.mocked(mockApiKeyService.verifyKeyByHashedKey).mockResolvedValue({
|
||||
valid: true,
|
||||
userId: 1,
|
||||
user: {
|
||||
role: UserPermissionRole.ADMIN,
|
||||
locked: false,
|
||||
email: "admin@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(isAdminGuard).mockResolvedValue({
|
||||
isAdmin: true,
|
||||
scope: ScopeOfAdmin.SystemWide,
|
||||
});
|
||||
|
||||
vi.mocked(isLockedOrBlocked).mockResolvedValue(false);
|
||||
|
||||
const middleware = {
|
||||
fn: verifyApiKey,
|
||||
};
|
||||
@@ -139,40 +177,25 @@ describe("Verify API key", () => {
|
||||
query: {
|
||||
apiKey: "cal_test_key",
|
||||
},
|
||||
prisma,
|
||||
});
|
||||
const hashedKey = hashAPIKey("test_key");
|
||||
await prismock.apiKey.create({
|
||||
data: {
|
||||
hashedKey,
|
||||
user: {
|
||||
create: {
|
||||
email: "org-admin@acme.com",
|
||||
role: UserPermissionRole.USER,
|
||||
locked: false,
|
||||
teams: {
|
||||
create: {
|
||||
accepted: true,
|
||||
role: MembershipRole.OWNER,
|
||||
team: {
|
||||
create: {
|
||||
name: "ACME",
|
||||
isOrganization: true,
|
||||
organizationSettings: {
|
||||
create: {
|
||||
isAdminAPIEnabled: true,
|
||||
orgAutoAcceptEmail: "acme.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
vi.mocked(mockApiKeyService.verifyKeyByHashedKey).mockResolvedValue({
|
||||
valid: true,
|
||||
userId: 2,
|
||||
user: {
|
||||
role: UserPermissionRole.USER,
|
||||
locked: false,
|
||||
email: "org-admin@acme.com",
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(isAdminGuard).mockResolvedValue({
|
||||
isAdmin: true,
|
||||
scope: ScopeOfAdmin.OrgOwnerOrAdmin,
|
||||
});
|
||||
|
||||
vi.mocked(isLockedOrBlocked).mockResolvedValue(false);
|
||||
|
||||
const middleware = {
|
||||
fn: verifyApiKey,
|
||||
};
|
||||
@@ -198,22 +221,25 @@ describe("Verify API key", () => {
|
||||
query: {
|
||||
apiKey: "cal_test_key",
|
||||
},
|
||||
prisma,
|
||||
});
|
||||
const hashedKey = hashAPIKey("test_key");
|
||||
await prismock.apiKey.create({
|
||||
data: {
|
||||
hashedKey,
|
||||
user: {
|
||||
create: {
|
||||
email: "locked@example.com",
|
||||
role: UserPermissionRole.USER,
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
|
||||
vi.mocked(mockApiKeyService.verifyKeyByHashedKey).mockResolvedValue({
|
||||
valid: true,
|
||||
userId: 3,
|
||||
user: {
|
||||
role: UserPermissionRole.USER,
|
||||
locked: true,
|
||||
email: "locked@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(isAdminGuard).mockResolvedValue({
|
||||
isAdmin: false,
|
||||
scope: ScopeOfAdmin.SystemWide,
|
||||
});
|
||||
|
||||
vi.mocked(isLockedOrBlocked).mockResolvedValue(true);
|
||||
|
||||
const middleware = {
|
||||
fn: verifyApiKey,
|
||||
};
|
||||
|
||||
@@ -3,21 +3,15 @@ import type { NextMiddleware } from "next-api-middleware";
|
||||
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
|
||||
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { ApiKeyService } from "@calcom/lib/server/service/ApiKeyService";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { isAdminGuard } from "../utils/isAdmin";
|
||||
import { isLockedOrBlocked } from "../utils/isLockedOrBlocked";
|
||||
import { ScopeOfAdmin } from "../utils/scopeOfAdmin";
|
||||
|
||||
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
|
||||
export const dateNotInPast = function (date: Date) {
|
||||
const now = new Date();
|
||||
if (now.setHours(0, 0, 0, 0) > date.setHours(0, 0, 0, 0)) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// This verifies the apiKey and sets the user if it is valid.
|
||||
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
||||
const deploymentRepo = new DeploymentRepository(prisma);
|
||||
@@ -32,24 +26,19 @@ export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
||||
|
||||
const strippedApiKey = `${req.query.apiKey}`.replace(process.env.API_KEY_PREFIX || "cal_", "");
|
||||
const hashedKey = hashAPIKey(strippedApiKey);
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { hashedKey },
|
||||
include: {
|
||||
user: {
|
||||
select: { role: true, locked: true, email: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!apiKey) return res.status(401).json({ error: "Your API key is not valid." });
|
||||
if (apiKey.expiresAt && dateNotInPast(apiKey.expiresAt)) {
|
||||
return res.status(401).json({ error: "This API key is expired." });
|
||||
|
||||
// Use service layer for API key verification
|
||||
const apiKeyRepo = new PrismaApiKeyRepository(prisma);
|
||||
const apiKeyService = new ApiKeyService({ apiKeyRepo });
|
||||
const result = await apiKeyService.verifyKeyByHashedKey(hashedKey);
|
||||
|
||||
if (!result.valid) {
|
||||
return res.status(401).json({ error: result.error });
|
||||
}
|
||||
if (!apiKey.userId || !apiKey.user)
|
||||
return res.status(404).json({ error: "No user found for this API key." });
|
||||
|
||||
// save the user id in the request for later use
|
||||
req.userId = apiKey.userId;
|
||||
req.user = apiKey.user;
|
||||
req.userId = result.userId!;
|
||||
req.user = result.user!;
|
||||
|
||||
const { isAdmin, scope } = await isAdminGuard(req);
|
||||
const userIsLockedOrBlocked = await isLockedOrBlocked(req);
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry";
|
||||
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
|
||||
|
||||
export async function isLockedOrBlocked(req: NextApiRequest) {
|
||||
const user = req.user;
|
||||
if (!user?.email) return false;
|
||||
return user.locked || (await checkIfEmailIsBlockedInWatchlistController(user.email));
|
||||
return (
|
||||
user.locked ||
|
||||
(await checkIfEmailIsBlockedInWatchlistController({
|
||||
email: user.email,
|
||||
organizationId: null,
|
||||
span: sentrySpan,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { checkUsername } from "@calcom/features/profile/lib/checkUsername";
|
||||
import { emailSchema } from "@calcom/lib/emailSchema";
|
||||
import { checkUsername } from "@calcom/lib/server/checkUsername";
|
||||
import { iso8601 } from "@calcom/prisma/zod-utils";
|
||||
import { UserSchema } from "@calcom/prisma/zod/modelSchema/UserSchema";
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import type { NextApiRequest } from "next";
|
||||
|
||||
import type { UserWithCalendars } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars";
|
||||
import { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { extractUserIdsFromQuery } from "~/lib/utils/extractUserIdsFromQuery";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments";
|
||||
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { deleteUser } from "@calcom/features/users/lib/userDeletionService";
|
||||
import { deleteUser } from "@calcom/features/users/lib/deleteUser";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { UserCreationService } from "@calcom/features/users/services/userCreationService";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { UserCreationService } from "@calcom/lib/server/service/userCreationService";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
|
||||
import { schemaUserCreateBodyParams } from "~/lib/validations/user";
|
||||
|
||||
@@ -66,7 +66,7 @@ vi.mock("@calcom/features/webhooks/lib/sendOrSchedulePayload", () => ({
|
||||
}));
|
||||
|
||||
const mockFindOriginalRescheduledBooking = vi.fn();
|
||||
vi.mock("@calcom/lib/server/repository/booking", () => ({
|
||||
vi.mock("@calcom/features/bookings/repositories/BookingRepository", () => ({
|
||||
BookingRepository: vi.fn().mockImplementation(() => ({
|
||||
findOriginalRescheduledBooking: mockFindOriginalRescheduledBooking,
|
||||
})),
|
||||
@@ -133,7 +133,7 @@ vi.mock("@calcom/features/bookings/lib/handleNewBooking/ensureAvailableUsers", (
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/repository/profile", () => ({
|
||||
vi.mock("@calcom/features/profile/repositories/ProfileRepository", () => ({
|
||||
ProfileRepository: {
|
||||
findManyForUser: vi.fn().mockResolvedValue([]),
|
||||
buildPersonalProfileFromUser: vi.fn().mockReturnValue({
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
/**
|
||||
* Unit Tests for POST /api/users
|
||||
*
|
||||
* These tests verify the API endpoint logic without touching the database.
|
||||
* All dependencies (UserCreationService) are mocked.
|
||||
*/
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, test, expect, vi } from "vitest";
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import handler from "../../../pages/api/users/_post";
|
||||
|
||||
@@ -18,9 +22,41 @@ vi.mock("@calcom/lib/server/i18n", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/features/profile/lib/checkUsername", () => ({
|
||||
checkUsername: vi.fn().mockResolvedValue({
|
||||
available: true,
|
||||
premium: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller", () => ({
|
||||
checkIfEmailIsBlockedInWatchlistController: vi.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
const mockCreate = vi.fn();
|
||||
vi.mock("@calcom/features/users/repositories/UserRepository", () => ({
|
||||
UserRepository: vi.fn().mockImplementation(() => ({
|
||||
create: mockCreate,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/auth/hashPassword", () => ({
|
||||
hashPassword: vi.fn().mockResolvedValue("hashed-password"),
|
||||
}));
|
||||
|
||||
vi.stubEnv("CALCOM_LICENSE_KEY", undefined);
|
||||
|
||||
describe("POST /api/users", () => {
|
||||
describe("POST /api/users - Unit Tests", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreate.mockResolvedValue({
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
username: "test",
|
||||
locked: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw 401 if not system-wide admin", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
@@ -34,7 +70,9 @@ describe("POST /api/users", () => {
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw a 400 if no email is provided", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
@@ -47,7 +85,9 @@ describe("POST /api/users", () => {
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw a 400 if no username is provided", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
@@ -60,15 +100,24 @@ describe("POST /api/users", () => {
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should create user successfully", async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
username: "testuser123",
|
||||
locked: false,
|
||||
organizationId: null,
|
||||
});
|
||||
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {
|
||||
email: "test@example.com",
|
||||
username: "test",
|
||||
username: "testuser123",
|
||||
},
|
||||
prisma: prismock,
|
||||
});
|
||||
req.isSystemWideAdmin = true;
|
||||
|
||||
@@ -76,57 +125,19 @@ describe("POST /api/users", () => {
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const userQuery = await prismock.user.findFirst({
|
||||
where: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
expect(userQuery).toEqual(
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: "test@example.com",
|
||||
username: "test",
|
||||
username: "testuser123",
|
||||
locked: false,
|
||||
organizationId: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should auto lock user if email is in watchlist", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {
|
||||
email: "test@example.com",
|
||||
username: "test",
|
||||
},
|
||||
prisma: prismock,
|
||||
});
|
||||
req.isSystemWideAdmin = true;
|
||||
|
||||
await prismock.watchlist.create({
|
||||
data: {
|
||||
type: "EMAIL",
|
||||
value: "test@example.com",
|
||||
severity: "CRITICAL",
|
||||
createdById: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const userQuery = await prismock.user.findFirst({
|
||||
where: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
expect(userQuery).toEqual(
|
||||
const responseData = JSON.parse(res._getData());
|
||||
expect(responseData.user).toEqual(
|
||||
expect.objectContaining({
|
||||
email: "test@example.com",
|
||||
username: "test",
|
||||
locked: true,
|
||||
username: "testuser123",
|
||||
organizationId: null,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,38 +1,52 @@
|
||||
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import type { NextApiRequest } from "next";
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
|
||||
|
||||
import { getWatchlistFeature } from "@calcom/features/di/watchlist/containers/watchlist";
|
||||
import type { WatchlistFeature } from "@calcom/features/watchlist/lib/facade/WatchlistFeature";
|
||||
|
||||
import { isLockedOrBlocked } from "../../../lib/utils/isLockedOrBlocked";
|
||||
|
||||
vi.mock("@calcom/features/di/watchlist/containers/watchlist", () => ({
|
||||
getWatchlistFeature: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("isLockedOrBlocked", () => {
|
||||
beforeEach(async () => {
|
||||
await prismock.watchlist.createMany({
|
||||
data: [
|
||||
{
|
||||
type: "DOMAIN",
|
||||
value: "spam.com",
|
||||
createdById: 1,
|
||||
},
|
||||
{
|
||||
type: "DOMAIN",
|
||||
value: "blocked.com",
|
||||
createdById: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
let mockWatchlistFeature: WatchlistFeature;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockGlobalBlocking = {
|
||||
isBlocked: vi.fn(),
|
||||
};
|
||||
const mockOrgBlocking = {
|
||||
isBlocked: vi.fn(),
|
||||
};
|
||||
|
||||
mockWatchlistFeature = {
|
||||
globalBlocking: mockGlobalBlocking,
|
||||
orgBlocking: mockOrgBlocking,
|
||||
} as unknown as WatchlistFeature;
|
||||
|
||||
vi.mocked(getWatchlistFeature).mockResolvedValue(mockWatchlistFeature);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should return false if no user in request", async () => {
|
||||
const req = { userId: null, user: null } as unknown as NextApiRequest;
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(false);
|
||||
|
||||
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return false if user has no email", async () => {
|
||||
const req = { userId: 123, user: { email: null } } as unknown as NextApiRequest;
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(false);
|
||||
|
||||
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return true if user is locked", async () => {
|
||||
@@ -46,6 +60,8 @@ describe("isLockedOrBlocked", () => {
|
||||
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(true);
|
||||
|
||||
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return true if user email domain is watchlisted", async () => {
|
||||
@@ -57,8 +73,12 @@ describe("isLockedOrBlocked", () => {
|
||||
},
|
||||
} as unknown as NextApiRequest;
|
||||
|
||||
vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked).mockResolvedValue({ isBlocked: true });
|
||||
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(true);
|
||||
|
||||
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).toHaveBeenCalledWith("test@blocked.com");
|
||||
});
|
||||
|
||||
it("should return false if user is not locked and email domain is not watchlisted", async () => {
|
||||
@@ -70,8 +90,12 @@ describe("isLockedOrBlocked", () => {
|
||||
},
|
||||
} as unknown as NextApiRequest;
|
||||
|
||||
vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked).mockResolvedValue({ isBlocked: false });
|
||||
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(false);
|
||||
|
||||
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).toHaveBeenCalledWith("test@example.com");
|
||||
});
|
||||
|
||||
it("should handle email domains case-insensitively", async () => {
|
||||
@@ -83,7 +107,11 @@ describe("isLockedOrBlocked", () => {
|
||||
},
|
||||
} as unknown as NextApiRequest;
|
||||
|
||||
vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked).mockResolvedValue({ isBlocked: true });
|
||||
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(true);
|
||||
|
||||
expect(vi.mocked(mockWatchlistFeature.globalBlocking.isBlocked)).toHaveBeenCalledWith("test@blocked.com");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2494,7 +2494,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toEqual(
|
||||
`Missing attendee phone number - it is required by the event type. Pass it as \"attendee.phoneNumber\" string in the request.`
|
||||
`Missing attendee phone number - it is required by the event type. Pass it as "attendee.phoneNumber" string in the request.`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3026,13 +3026,154 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("event type with max bookings count per booker", () => {
|
||||
it("should not allow booking more than maximumActiveBookings per attendee", async () => {
|
||||
const eventTypeIdWithMaxBookerBookings = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
slug: `max-bookings-count-per-booker-${randomString(10)}`,
|
||||
length: 60,
|
||||
title: "Event Type with max bookings count per booker",
|
||||
maxActiveBookingsPerBooker: 1,
|
||||
},
|
||||
user.id
|
||||
);
|
||||
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 13, 10, 0, 0)).toISOString(),
|
||||
eventTypeId: eventTypeIdWithMaxBookerBookings.id,
|
||||
attendee: {
|
||||
name: "alice",
|
||||
email: "alice@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
|
||||
const response2 = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response2.body.error.message).toBe(
|
||||
"Attendee with this email can't book because the maximum number of active bookings has been reached."
|
||||
);
|
||||
|
||||
const body2: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 13, 12, 0, 0)).toISOString(),
|
||||
eventTypeId: eventTypeIdWithMaxBookerBookings.id,
|
||||
attendee: {
|
||||
name: "bob",
|
||||
email: "bob@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
const response3 = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body2)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const responseBody2: CreateBookingOutput_2024_08_13 = response3.body;
|
||||
expect(responseBody2.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody2.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody2.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody2.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody2.data;
|
||||
expect(data.id).toBeDefined();
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should not allow booking more than maximumActiveBookings per attendee and offer rescheduling", async () => {
|
||||
const eventTypeIdWithMaxBookerBookings = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
slug: `max-bookings-count-per-booker-with-reschedule-${randomString(10)}`,
|
||||
length: 60,
|
||||
title: "Event Type with max bookings count per booker with reschedule",
|
||||
maxActiveBookingsPerBooker: 1,
|
||||
maxActiveBookingPerBookerOfferReschedule: true,
|
||||
},
|
||||
user.id
|
||||
);
|
||||
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2040, 0, 13, 14, 0, 0)).toISOString(),
|
||||
eventTypeId: eventTypeIdWithMaxBookerBookings.id,
|
||||
attendee: {
|
||||
name: "charlie",
|
||||
email: "charlie@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
|
||||
const response2 = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response2.body.error.message).toBe(
|
||||
`Attendee with this email can't book because the maximum number of active bookings has been reached. You can reschedule your existing booking (${responseBody.data.uid}) to a new timeslot instead.`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 {
|
||||
function responseDataIsRecurranceBooking(data: unknown): data is RecurringBookingOutput_2024_08_13 {
|
||||
return (
|
||||
!Array.isArray(data) &&
|
||||
typeof data === "object" &&
|
||||
data !== null &&
|
||||
data &&
|
||||
"id" in data &&
|
||||
"recurringBookingUid" in data
|
||||
@@ -3048,11 +3189,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
|
||||
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
|
||||
function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 {
|
||||
return !Array.isArray(data) && typeof data === "object" && data !== null && data && "id" in data;
|
||||
}
|
||||
|
||||
function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] {
|
||||
function responseDataIsRecurringBooking(data: unknown): data is RecurringBookingOutput_2024_08_13[] {
|
||||
return Array.isArray(data);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -48,6 +48,19 @@ export class ErrorsBookingsService_2024_08_13 {
|
||||
throw new BadRequestException("Attempting to book a meeting in the past.");
|
||||
} else if (error.message === "hosts_unavailable_for_booking") {
|
||||
throw new BadRequestException(hostsUnavaile);
|
||||
} else if (error.message === "booker_limit_exceeded_error") {
|
||||
throw new BadRequestException(
|
||||
"Attendee with this email can't book because the maximum number of active bookings has been reached."
|
||||
);
|
||||
} else if (error.message === "booker_limit_exceeded_error_reschedule") {
|
||||
const errorData =
|
||||
"data" in error ? (error.data as { rescheduleUid: string }) : { rescheduleUid: undefined };
|
||||
let message =
|
||||
"Attendee with this email can't book because the maximum number of active bookings has been reached.";
|
||||
if (errorData?.rescheduleUid) {
|
||||
message += ` You can reschedule your existing booking (${errorData.rescheduleUid}) to a new timeslot instead.`;
|
||||
}
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -817,7 +817,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
// for an individual person, so api users need to call booking by booking using uid + seatUid to cancel it.
|
||||
return {
|
||||
uid: bookingUid,
|
||||
cancellationReason: "",
|
||||
cancellationReason: inputBooking.cancellationReason || "",
|
||||
allRemainingBookings: false,
|
||||
seatReferenceUid: inputBooking.seatUid,
|
||||
};
|
||||
|
||||
+5
-1
@@ -137,7 +137,11 @@ export class EventTypesService_2024_04_15 {
|
||||
!bookingFields.find((field) => field.type === "email") &&
|
||||
!bookingFields.find((field) => field.type === "phone")
|
||||
) {
|
||||
bookingFields.push({ ...systemBeforeFieldEmail, type: BaseField.email, editable: Editable.system });
|
||||
bookingFields.push({
|
||||
...systemBeforeFieldEmail,
|
||||
type: BaseField.email,
|
||||
editable: Editable.systemButOptional,
|
||||
});
|
||||
}
|
||||
|
||||
await updateEventType({
|
||||
|
||||
+182
-9
@@ -31,16 +31,18 @@ import {
|
||||
FrequencyInput,
|
||||
} from "@calcom/platform-enums";
|
||||
import { SchedulingType } from "@calcom/platform-libraries";
|
||||
import type {
|
||||
ApiSuccessResponse,
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
EventTypeOutput_2024_06_14,
|
||||
GuestsDefaultFieldOutput_2024_06_14,
|
||||
NameDefaultFieldInput_2024_06_14,
|
||||
NotesDefaultFieldInput_2024_06_14,
|
||||
SplitNameDefaultFieldOutput_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
import {
|
||||
type ApiSuccessResponse,
|
||||
type CreateEventTypeInput_2024_06_14,
|
||||
type EventTypeOutput_2024_06_14,
|
||||
type GuestsDefaultFieldOutput_2024_06_14,
|
||||
type NameDefaultFieldInput_2024_06_14,
|
||||
type NotesDefaultFieldInput_2024_06_14,
|
||||
type SplitNameDefaultFieldOutput_2024_06_14,
|
||||
type UpdateEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
import { FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/validators/CantHaveRecurrenceAndBookerActiveBookingsLimit";
|
||||
import { REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR } from "@calcom/platform-types/utils/RequiresOneOfPropertiesWhenNotDisabled";
|
||||
import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client";
|
||||
|
||||
const orderBySlug = (a: { slug: string }, b: { slug: string }) => {
|
||||
@@ -1442,6 +1444,177 @@ describe("Event types Endpoints", () => {
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
describe("bookerActiveBookingsLimit", () => {
|
||||
describe("negative tests", () => {
|
||||
it("should not create an event type with bookerActiveBookingsLimit and recurrence", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class with bookerActiveBookingsLimit",
|
||||
slug: "coding-class-booker-active-bookings-limit",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
bookerActiveBookingsLimit: {
|
||||
maximumActiveBookings: 2,
|
||||
offerReschedule: true,
|
||||
},
|
||||
recurrence: {
|
||||
frequency: FrequencyInput.weekly,
|
||||
interval: 2,
|
||||
occurrences: 10,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
|
||||
expect(
|
||||
response.body.error.message.includes(FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should not allow creating an event type with bookerActiveBookingsLimit disabled:false", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class with bookerActiveBookingsLimit disabled:false",
|
||||
slug: "coding-class-booker-active-bookings-limit-disabled",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
// note(Lauris): disabled false means that it is enabled so it should have maximumActiveBookings and / or offerReschedule provided
|
||||
bookerActiveBookingsLimit: {
|
||||
disabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message.includes(REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("positive tests", () => {
|
||||
let eventTypeWithBookerActiveBookingsLimitId: number;
|
||||
|
||||
it("should create an event type with bookerActiveBookingsLimit", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class with bookerActiveBookingsLimit",
|
||||
slug: "coding-class-booker-active-bookings-limit",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
bookerActiveBookingsLimit: {
|
||||
maximumActiveBookings: 2,
|
||||
offerReschedule: true,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit);
|
||||
eventTypeWithBookerActiveBookingsLimitId = createdEventType.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should update an event type with bookerActiveBookingsLimit", async () => {
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
bookerActiveBookingsLimit: {
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventTypeWithBookerActiveBookingsLimitId}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const updatedEventType = responseBody.data;
|
||||
expect(updatedEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit);
|
||||
eventTypeWithBookerActiveBookingsLimitId = updatedEventType.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should create an event type with bookerActiveBookingsLimit and recurrence disabled", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class with bookerActiveBookingsLimit and recurrence disabled",
|
||||
slug: "coding-class-booker-active-bookings-limit-recurrence-disabled",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
bookerActiveBookingsLimit: {
|
||||
maximumActiveBookings: 2,
|
||||
offerReschedule: true,
|
||||
},
|
||||
recurrence: {
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit);
|
||||
eventTypeWithBookerActiveBookingsLimitId = createdEventType.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should create an event type with recurrence and bookerActiveBookingsLimit disabled", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class with bookerActiveBookingsLimit disabled and recurrence",
|
||||
slug: "coding-class-booker-active-bookings-limit-disabled-and-recurrence",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
bookerActiveBookingsLimit: {
|
||||
disabled: true,
|
||||
},
|
||||
recurrence: {
|
||||
frequency: FrequencyInput.weekly,
|
||||
interval: 2,
|
||||
occurrences: 10,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.bookerActiveBookingsLimit).toEqual(body.bookerActiveBookingsLimit);
|
||||
eventTypeWithBookerActiveBookingsLimitId = createdEventType.id;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
|
||||
+65
-25
@@ -28,7 +28,11 @@ import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { getApps, getUsersCredentialsIncludeServiceAccountKey } from "@calcom/platform-libraries/app-store";
|
||||
import { validateCustomEventName, EventTypeMetaDataSchema } from "@calcom/platform-libraries/event-types";
|
||||
import {
|
||||
validateCustomEventName,
|
||||
EventTypeMetaDataSchema,
|
||||
EventTypeMetadata,
|
||||
} from "@calcom/platform-libraries/event-types";
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
DestinationCalendar_2024_06_14,
|
||||
@@ -67,11 +71,13 @@ export class InputEventTypesService_2024_06_14 {
|
||||
eventName: transformedBody.eventName,
|
||||
});
|
||||
|
||||
transformedBody.destinationCalendar &&
|
||||
(await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar));
|
||||
if (transformedBody.destinationCalendar) {
|
||||
await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar);
|
||||
}
|
||||
|
||||
transformedBody.useEventTypeDestinationCalendarEmail &&
|
||||
(await this.validateInputUseDestinationCalendarEmail(user.id));
|
||||
if (transformedBody.useEventTypeDestinationCalendarEmail) {
|
||||
await this.validateInputUseDestinationCalendarEmail(user.id);
|
||||
}
|
||||
|
||||
return transformedBody;
|
||||
}
|
||||
@@ -93,11 +99,13 @@ export class InputEventTypesService_2024_06_14 {
|
||||
eventName: transformedBody.eventName,
|
||||
});
|
||||
|
||||
transformedBody.destinationCalendar &&
|
||||
(await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar));
|
||||
if (transformedBody.destinationCalendar) {
|
||||
await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar);
|
||||
}
|
||||
|
||||
transformedBody.useEventTypeDestinationCalendarEmail &&
|
||||
(await this.validateInputUseDestinationCalendarEmail(user.id));
|
||||
if (transformedBody.useEventTypeDestinationCalendarEmail) {
|
||||
await this.validateInputUseDestinationCalendarEmail(user.id);
|
||||
}
|
||||
|
||||
return transformedBody;
|
||||
}
|
||||
@@ -119,6 +127,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
customName,
|
||||
useDestinationCalendarEmail,
|
||||
disableGuests,
|
||||
bookerActiveBookingsLimit,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
|
||||
@@ -130,6 +139,17 @@ export class InputEventTypesService_2024_06_14 {
|
||||
? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests)
|
||||
: bookingFields;
|
||||
|
||||
const maxActiveBookingsPerBooker = bookerActiveBookingsLimit
|
||||
? this.transformInputBookerActiveBookingsLimit(bookerActiveBookingsLimit)
|
||||
: {};
|
||||
|
||||
const metadata: EventTypeMetadata = {
|
||||
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
|
||||
requiresConfirmationThreshold:
|
||||
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
|
||||
multipleDuration: lengthInMinutesOptions,
|
||||
};
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
length: lengthInMinutes,
|
||||
@@ -140,12 +160,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
? this.transformInputIntervalLimits(bookingLimitsDuration)
|
||||
: undefined,
|
||||
...this.transformInputBookingWindow(bookingWindow),
|
||||
metadata: {
|
||||
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
|
||||
requiresConfirmationThreshold:
|
||||
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
|
||||
multipleDuration: lengthInMinutesOptions,
|
||||
},
|
||||
metadata,
|
||||
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
|
||||
requiresConfirmationWillBlockSlot:
|
||||
confirmationPolicyTransformed?.requiresConfirmationWillBlockSlot ?? undefined,
|
||||
@@ -154,11 +169,27 @@ export class InputEventTypesService_2024_06_14 {
|
||||
...this.transformInputSeatOptions(seats),
|
||||
eventName: customName,
|
||||
useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail,
|
||||
...maxActiveBookingsPerBooker,
|
||||
};
|
||||
|
||||
return eventType;
|
||||
}
|
||||
|
||||
transformInputBookerActiveBookingsLimit(
|
||||
bookerActiveBookingsLimit: CreateEventTypeInput_2024_06_14["bookerActiveBookingsLimit"]
|
||||
) {
|
||||
if (!bookerActiveBookingsLimit || bookerActiveBookingsLimit.disabled) {
|
||||
return {
|
||||
maxActiveBookingsPerBooker: null,
|
||||
maxActiveBookingPerBookerOfferReschedule: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
maxActiveBookingsPerBooker: bookerActiveBookingsLimit?.maximumActiveBookings,
|
||||
maxActiveBookingPerBookerOfferReschedule: bookerActiveBookingsLimit?.offerReschedule,
|
||||
};
|
||||
}
|
||||
|
||||
async transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14, eventTypeId: number) {
|
||||
const {
|
||||
lengthInMinutes,
|
||||
@@ -176,10 +207,11 @@ export class InputEventTypesService_2024_06_14 {
|
||||
customName,
|
||||
useDestinationCalendarEmail,
|
||||
disableGuests,
|
||||
bookerActiveBookingsLimit,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
const eventTypeDb = await this.eventTypesRepository.getEventTypeWithMetaData(eventTypeId);
|
||||
const metadataTransformed = !!eventTypeDb?.metadata
|
||||
const metadataTransformed = eventTypeDb?.metadata
|
||||
? EventTypeMetaDataSchema.parse(eventTypeDb.metadata)
|
||||
: {};
|
||||
|
||||
@@ -190,6 +222,18 @@ export class InputEventTypesService_2024_06_14 {
|
||||
? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests)
|
||||
: bookingFields;
|
||||
|
||||
const maxActiveBookingsPerBooker = bookerActiveBookingsLimit
|
||||
? this.transformInputBookerActiveBookingsLimit(bookerActiveBookingsLimit)
|
||||
: {};
|
||||
|
||||
const metadata: EventTypeMetadata = {
|
||||
...metadataTransformed,
|
||||
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
|
||||
requiresConfirmationThreshold:
|
||||
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
|
||||
multipleDuration: lengthInMinutesOptions,
|
||||
};
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
length: lengthInMinutes,
|
||||
@@ -202,13 +246,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
? this.transformInputIntervalLimits(bookingLimitsDuration)
|
||||
: undefined,
|
||||
...this.transformInputBookingWindow(bookingWindow),
|
||||
metadata: {
|
||||
...metadataTransformed,
|
||||
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
|
||||
requiresConfirmationThreshold:
|
||||
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
|
||||
multipleDuration: lengthInMinutesOptions,
|
||||
},
|
||||
metadata,
|
||||
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
|
||||
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
|
||||
requiresConfirmationWillBlockSlot:
|
||||
@@ -217,6 +255,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
...this.transformInputSeatOptions(seats),
|
||||
eventName: customName,
|
||||
useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail,
|
||||
...maxActiveBookingsPerBooker,
|
||||
};
|
||||
|
||||
return eventType;
|
||||
@@ -319,7 +358,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
|
||||
transformInputBookingWindow(inputBookingWindow: CreateEventTypeInput_2024_06_14["bookingWindow"]) {
|
||||
const res = transformFutureBookingLimitsApiToInternal(inputBookingWindow);
|
||||
return !!res ? res : {};
|
||||
return res ? res : {};
|
||||
}
|
||||
|
||||
transformInputBookerLayouts(inputBookerLayouts: CreateEventTypeInput_2024_06_14["bookerLayouts"]) {
|
||||
@@ -369,7 +408,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
requiresConfirmationDb = eventTypeDb?.requiresConfirmation ?? false;
|
||||
}
|
||||
|
||||
const seatsPerTimeSlotFinal = !!seatsPerTimeSlot ? seatsPerTimeSlot : seatsPerTimeSlotDb;
|
||||
const seatsPerTimeSlotFinal = seatsPerTimeSlot ? seatsPerTimeSlot : seatsPerTimeSlotDb;
|
||||
const seatsEnabledFinal = !!seatsPerTimeSlotFinal && seatsPerTimeSlotFinal > 0;
|
||||
|
||||
const locationsFinal = locations !== undefined ? locations : locationsDb;
|
||||
@@ -394,6 +433,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
transformLocations(locations: any) {
|
||||
if (!locations) return [];
|
||||
|
||||
|
||||
+28
@@ -92,6 +92,8 @@ type Input = Pick<
|
||||
| "calVideoSettings"
|
||||
| "hidden"
|
||||
| "bookingRequiresAuthentication"
|
||||
| "maxActiveBookingsPerBooker"
|
||||
| "maxActiveBookingPerBookerOfferReschedule"
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
@@ -165,6 +167,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
periodEndDate: databaseEventType.periodEndDate,
|
||||
} as TransformFutureBookingsLimitSchema_2024_06_14);
|
||||
const destinationCalendar = this.transformDestinationCalendar(databaseEventType.destinationCalendar);
|
||||
const bookerActiveBookingsLimit = this.transformBookerActiveBookingsLimit(databaseEventType);
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -210,9 +213,29 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
calVideoSettings,
|
||||
hidden,
|
||||
bookingRequiresAuthentication,
|
||||
bookerActiveBookingsLimit,
|
||||
};
|
||||
}
|
||||
|
||||
transformBookerActiveBookingsLimit(databaseEventType: Input) {
|
||||
const noMaxActiveBookingsPerBooker =
|
||||
!databaseEventType.maxActiveBookingsPerBooker && databaseEventType.maxActiveBookingsPerBooker !== 0;
|
||||
const noMaxActiveBookingPerBookerOfferReschedule =
|
||||
!databaseEventType.maxActiveBookingPerBookerOfferReschedule;
|
||||
|
||||
if (noMaxActiveBookingsPerBooker && noMaxActiveBookingPerBookerOfferReschedule) {
|
||||
return {
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
maximumActiveBookings: databaseEventType.maxActiveBookingsPerBooker ?? undefined,
|
||||
offerReschedule: databaseEventType.maxActiveBookingPerBookerOfferReschedule ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
transformLocations(locationDb: any) {
|
||||
if (!locationDb) return [];
|
||||
|
||||
@@ -239,6 +262,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
transformBookingFields(bookingFields: any) {
|
||||
if (!bookingFields) return [];
|
||||
|
||||
@@ -273,6 +297,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
return this.transformBookingFields(defaultBookingFields);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
transformRecurringEvent(recurringEvent: any) {
|
||||
if (!recurringEvent) return null;
|
||||
const recurringEventParsed = parseRecurringEvent(recurringEvent);
|
||||
@@ -280,6 +305,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
return transformRecurrenceInternalToApi(recurringEventParsed);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
transformMetadata(metadata: any) {
|
||||
if (!metadata) return {};
|
||||
return EventTypeMetaDataSchema.parse(metadata);
|
||||
@@ -301,6 +327,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
transformIntervalLimits(bookingLimits: any) {
|
||||
const bookingLimitsParsed = parseBookingLimit(bookingLimits);
|
||||
return transformIntervalLimitsInternalToApi(bookingLimitsParsed);
|
||||
@@ -327,6 +354,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
transformEventTypeColor(eventTypeColor: any) {
|
||||
if (!eventTypeColor) return undefined;
|
||||
const parsedeventTypeColor = parseEventTypeColor(eventTypeColor);
|
||||
|
||||
+2
@@ -38,6 +38,8 @@ export type InputEventTransformed_2024_06_14 = Omit<
|
||||
bookingFields?: ReturnType<typeof transformBookingFieldsApiToInternal>;
|
||||
durationLimits?: ReturnType<typeof transformIntervalLimitsApiToInternal>;
|
||||
recurringEvent?: ReturnType<typeof transformRecurrenceApiToInternal>;
|
||||
maxActiveBookingsPerBooker?: number;
|
||||
maxActiveBookingPerBookerOfferReschedule?: boolean;
|
||||
eventTypeColor?: ReturnType<typeof transformEventColorsApiToInternal>;
|
||||
useEventTypeDestinationCalendarEmail?: boolean;
|
||||
} & Partial<
|
||||
|
||||
+1
-1
@@ -556,7 +556,7 @@ export const systemBeforeFieldEmail: EmailSystemField = {
|
||||
type: "email",
|
||||
name: "email",
|
||||
required: true,
|
||||
editable: "system",
|
||||
editable: "system-but-optional",
|
||||
sources: [
|
||||
{
|
||||
label: "Default",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
Version,
|
||||
@@ -21,8 +22,16 @@ import {
|
||||
ApiExcludeController as DocsExcludeController,
|
||||
ApiOperation,
|
||||
} from "@nestjs/swagger";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type {
|
||||
GetAvailabilityListHandlerReturn,
|
||||
DuplicateScheduleHandlerReturn,
|
||||
} from "@calcom/platform-libraries/schedules";
|
||||
import { getAvailabilityListHandler, duplicateScheduleHandler } from "@calcom/platform-libraries/schedules";
|
||||
import type { CreateScheduleHandlerReturn, CreateScheduleSchema } from "@calcom/platform-libraries/schedules";
|
||||
import { createScheduleHandler } from "@calcom/platform-libraries/schedules";
|
||||
import { FindDetailedScheduleByIdReturnType } from "@calcom/platform-libraries/schedules";
|
||||
import { ApiResponse, UpdateAtomScheduleDto } from "@calcom/platform-types";
|
||||
|
||||
@@ -63,6 +72,22 @@ export class AtomsSchedulesController {
|
||||
data: schedule,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/schedules/all")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@Permissions([SCHEDULE_READ])
|
||||
async getAllUserSchedules(
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<ApiResponse<Awaited<GetAvailabilityListHandlerReturn>>> {
|
||||
const userSchedules = await getAvailabilityListHandler({ ctx: { user } });
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: userSchedules,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("schedules/:scheduleId")
|
||||
@Permissions([SCHEDULE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@@ -83,4 +108,36 @@ export class AtomsSchedulesController {
|
||||
data: updatedSchedule,
|
||||
};
|
||||
}
|
||||
|
||||
@Post("schedules/create")
|
||||
@Permissions([SCHEDULE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiOperation({ summary: "Create atom schedule" })
|
||||
async createSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() bodySchedule: z.infer<typeof CreateScheduleSchema>
|
||||
): Promise<ApiResponse<CreateScheduleHandlerReturn>> {
|
||||
const createdSchedule = await createScheduleHandler({ input: bodySchedule, ctx: { user } });
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: createdSchedule,
|
||||
};
|
||||
}
|
||||
|
||||
@Post("schedules/:scheduleId/duplicate")
|
||||
@Permissions([SCHEDULE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiOperation({ summary: "Duplicate existing schedule" })
|
||||
async duplicateExistingSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("scheduleId", ParseIntPipe) scheduleId: number
|
||||
): Promise<ApiResponse<Awaited<DuplicateScheduleHandlerReturn>>> {
|
||||
const duplicatedSchedule = await duplicateScheduleHandler({ ctx: { user }, input: { scheduleId } });
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: duplicatedSchedule,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -9,7 +9,7 @@ import {
|
||||
import { Request } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class IsWorkflowInTeam implements CanActivate {
|
||||
export class IsEventTypeWorkflowInTeam implements CanActivate {
|
||||
constructor(private workflowsRepository: WorkflowsRepository) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
@@ -41,10 +41,13 @@ export class IsWorkflowInTeam implements CanActivate {
|
||||
teamId: string,
|
||||
workflowId: string
|
||||
): Promise<{ canAccess: boolean; workflow?: WorkflowType }> {
|
||||
const workflow = await this.workflowsRepository.getTeamWorkflowById(Number(teamId), Number(workflowId));
|
||||
const workflow = await this.workflowsRepository.getEventTypeTeamWorkflowById(
|
||||
Number(teamId),
|
||||
Number(workflowId)
|
||||
);
|
||||
|
||||
if (!workflow) {
|
||||
throw new NotFoundException(`IsWorkflowInTeam - workflow (${workflowId}) not found.`);
|
||||
throw new NotFoundException(`IsWorkflowInTeam - event-type workflow (${workflowId}) not found.`);
|
||||
}
|
||||
|
||||
if (workflow.teamId === Number(teamId)) {
|
||||
@@ -0,0 +1,59 @@
|
||||
import { WorkflowsRepository, WorkflowType } from "@/modules/workflows/workflows.repository";
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class IsRoutingFormWorkflowInTeam implements CanActivate {
|
||||
constructor(private workflowsRepository: WorkflowsRepository) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { workflow?: WorkflowType }>();
|
||||
const teamId: string = request.params.teamId;
|
||||
const workflowId: string = request.params.workflowId;
|
||||
|
||||
if (!workflowId) {
|
||||
throw new ForbiddenException("IsWorkflowInTeam - No workflow found in request params.");
|
||||
}
|
||||
|
||||
if (!teamId) {
|
||||
throw new ForbiddenException("IsWorkflowInTeam - No team id found in request params.");
|
||||
}
|
||||
|
||||
const { canAccess, workflow } = await this.checkIfWorkflowIsInTeam(teamId, workflowId);
|
||||
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException(
|
||||
`IsTeamInOrg - Workflow with id=${workflowId} is not part of the team with id=${teamId}.`
|
||||
);
|
||||
}
|
||||
|
||||
request.workflow = workflow;
|
||||
return true;
|
||||
}
|
||||
|
||||
async checkIfWorkflowIsInTeam(
|
||||
teamId: string,
|
||||
workflowId: string
|
||||
): Promise<{ canAccess: boolean; workflow?: WorkflowType }> {
|
||||
const workflow = await this.workflowsRepository.getRoutingFormTeamWorkflowById(
|
||||
Number(teamId),
|
||||
Number(workflowId)
|
||||
);
|
||||
|
||||
if (!workflow) {
|
||||
throw new NotFoundException(`IsWorkflowInTeam - routing form workflow (${workflowId}) not found.`);
|
||||
}
|
||||
|
||||
if (workflow.teamId === Number(teamId)) {
|
||||
return { canAccess: true, workflow };
|
||||
}
|
||||
|
||||
return { canAccess: false };
|
||||
}
|
||||
}
|
||||
+13
@@ -355,6 +355,11 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
darkThemeHex: "#292929",
|
||||
lightThemeHex: "#fafafa",
|
||||
},
|
||||
emailSettings: {
|
||||
disableEmailsToAttendees: true,
|
||||
disableEmailsToHosts: true,
|
||||
},
|
||||
rescheduleWithSameRoundRobinHost: true,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -385,6 +390,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
||||
expect(data.color).toEqual(body.color);
|
||||
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
|
||||
expect(data.emailSettings).toEqual(body.emailSettings);
|
||||
collectiveEventType = responseBody.data;
|
||||
});
|
||||
});
|
||||
@@ -600,6 +606,11 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
const body: UpdateTeamEventTypeInput_2024_06_14 = {
|
||||
hosts: newHosts,
|
||||
successRedirectUrl: "https://new-url-success.com",
|
||||
emailSettings: {
|
||||
disableEmailsToAttendees: false,
|
||||
disableEmailsToHosts: false,
|
||||
},
|
||||
rescheduleWithSameRoundRobinHost: false,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -614,6 +625,8 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(eventType.successRedirectUrl).toEqual("https://new-url-success.com");
|
||||
expect(eventType.title).toEqual(collectiveEventType.title);
|
||||
expect(eventType.hosts.length).toEqual(1);
|
||||
expect(eventType.emailSettings).toEqual(body.emailSettings);
|
||||
expect(eventType.rescheduleWithSameRoundRobinHost).toEqual(body.rescheduleWithSameRoundRobinHost);
|
||||
evaluateHost(eventType.hosts[0], newHosts[0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,10 +8,12 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { SchedulingType } from "@calcom/platform-libraries";
|
||||
import { EventTypeMetadata } from "@calcom/platform-libraries/event-types";
|
||||
import {
|
||||
CreateTeamEventTypeInput_2024_06_14,
|
||||
UpdateTeamEventTypeInput_2024_06_14,
|
||||
HostPriority,
|
||||
EmailSettings_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
export type TransformedCreateTeamEventTypeInput = Awaited<
|
||||
@@ -49,14 +51,16 @@ export class InputOrganizationsEventTypesService {
|
||||
eventName: transformedBody.eventName,
|
||||
});
|
||||
|
||||
transformedBody.destinationCalendar &&
|
||||
(await this.inputEventTypesService.validateInputDestinationCalendar(
|
||||
if (transformedBody.destinationCalendar) {
|
||||
await this.inputEventTypesService.validateInputDestinationCalendar(
|
||||
userId,
|
||||
transformedBody.destinationCalendar
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
transformedBody.useEventTypeDestinationCalendarEmail &&
|
||||
(await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId));
|
||||
if (transformedBody.useEventTypeDestinationCalendarEmail) {
|
||||
await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId);
|
||||
}
|
||||
|
||||
return transformedBody;
|
||||
}
|
||||
@@ -83,14 +87,16 @@ export class InputOrganizationsEventTypesService {
|
||||
eventName: transformedBody.eventName,
|
||||
});
|
||||
|
||||
transformedBody.destinationCalendar &&
|
||||
(await this.inputEventTypesService.validateInputDestinationCalendar(
|
||||
if (transformedBody.destinationCalendar) {
|
||||
await this.inputEventTypesService.validateInputDestinationCalendar(
|
||||
userId,
|
||||
transformedBody.destinationCalendar
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
transformedBody.useEventTypeDestinationCalendarEmail &&
|
||||
(await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId));
|
||||
if (transformedBody.useEventTypeDestinationCalendarEmail) {
|
||||
await this.inputEventTypesService.validateInputUseDestinationCalendarEmail(userId);
|
||||
}
|
||||
|
||||
return transformedBody;
|
||||
}
|
||||
@@ -110,7 +116,7 @@ export class InputOrganizationsEventTypesService {
|
||||
teamId: number,
|
||||
inputEventType: CreateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
const { hosts, assignAllTeamMembers, locations, ...rest } = inputEventType;
|
||||
const { hosts, assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
|
||||
|
||||
const eventType = this.inputEventTypesService.transformInputCreateEventType(rest);
|
||||
|
||||
@@ -123,11 +129,15 @@ export class InputOrganizationsEventTypesService {
|
||||
|
||||
const children = await this.getChildEventTypesForManagedEventTypeCreate(inputEventType, teamId);
|
||||
|
||||
const metadata =
|
||||
let metadata =
|
||||
rest.schedulingType === "MANAGED"
|
||||
? { managedEventConfig: {}, ...eventType.metadata }
|
||||
: eventType.metadata;
|
||||
|
||||
if (emailSettings) {
|
||||
metadata = this.addEmailSettingsToMetadata(emailSettings, metadata);
|
||||
}
|
||||
|
||||
const teamEventType = {
|
||||
...eventType,
|
||||
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
|
||||
@@ -145,12 +155,42 @@ export class InputOrganizationsEventTypesService {
|
||||
return teamEventType;
|
||||
}
|
||||
|
||||
private addEmailSettingsToMetadata(
|
||||
emailSettings: EmailSettings_2024_06_14,
|
||||
metadata: NonNullable<EventTypeMetadata>
|
||||
) {
|
||||
if (
|
||||
emailSettings?.disableEmailsToAttendees === undefined &&
|
||||
emailSettings?.disableEmailsToHosts === undefined
|
||||
) {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
const clonedMetadata = structuredClone(metadata);
|
||||
|
||||
if (!clonedMetadata.disableStandardEmails) {
|
||||
clonedMetadata.disableStandardEmails = {};
|
||||
}
|
||||
if (!clonedMetadata.disableStandardEmails.all) {
|
||||
clonedMetadata.disableStandardEmails.all = {};
|
||||
}
|
||||
|
||||
if (emailSettings?.disableEmailsToAttendees !== undefined) {
|
||||
clonedMetadata.disableStandardEmails.all.attendee = emailSettings.disableEmailsToAttendees;
|
||||
}
|
||||
if (emailSettings?.disableEmailsToHosts !== undefined) {
|
||||
clonedMetadata.disableStandardEmails.all.host = emailSettings.disableEmailsToHosts;
|
||||
}
|
||||
|
||||
return clonedMetadata;
|
||||
}
|
||||
|
||||
async transformInputUpdateTeamEventType(
|
||||
eventTypeId: number,
|
||||
teamId: number,
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
const { hosts, assignAllTeamMembers, locations, ...rest } = inputEventType;
|
||||
const { hosts, assignAllTeamMembers, locations, emailSettings, ...rest } = inputEventType;
|
||||
|
||||
const eventType = await this.inputEventTypesService.transformInputUpdateEventType(rest, eventTypeId);
|
||||
const dbEventType = await this.teamsEventTypesRepository.getTeamEventType(teamId, eventTypeId);
|
||||
@@ -164,6 +204,12 @@ export class InputOrganizationsEventTypesService {
|
||||
? await this.getChildEventTypesForManagedEventTypeUpdate(eventTypeId, inputEventType, teamId)
|
||||
: undefined;
|
||||
|
||||
let metadata = eventType.metadata;
|
||||
|
||||
if (emailSettings) {
|
||||
metadata = this.addEmailSettingsToMetadata(emailSettings, metadata);
|
||||
}
|
||||
|
||||
const teamEventType = {
|
||||
...eventType,
|
||||
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
|
||||
@@ -175,6 +221,7 @@ export class InputOrganizationsEventTypesService {
|
||||
assignAllTeamMembers,
|
||||
children,
|
||||
locations: locations ? this.transformInputTeamLocations(locations) : undefined,
|
||||
metadata,
|
||||
};
|
||||
|
||||
return teamEventType;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { SchedulingType } from "@calcom/platform-libraries";
|
||||
import { EventTypeMetadata } from "@calcom/platform-libraries/event-types";
|
||||
import type { HostPriority, TeamEventTypeResponseHost } from "@calcom/platform-types";
|
||||
import type {
|
||||
Team,
|
||||
@@ -84,6 +85,9 @@ type Input = Pick<
|
||||
| "calVideoSettings"
|
||||
| "hidden"
|
||||
| "bookingRequiresAuthentication"
|
||||
| "rescheduleWithSameRoundRobinHost"
|
||||
| "maxActiveBookingPerBookerOfferReschedule"
|
||||
| "maxActiveBookingsPerBooker"
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
@@ -95,7 +99,12 @@ export class OutputOrganizationsEventTypesService {
|
||||
) {}
|
||||
|
||||
async getResponseTeamEventType(databaseEventType: Input, isOrgTeamEvent: boolean) {
|
||||
const { teamId, userId, parentId, assignAllTeamMembers } = databaseEventType;
|
||||
const metadata = this.outputEventTypesService.transformMetadata(databaseEventType.metadata);
|
||||
|
||||
const emailSettings = this.transformEmailSettings(metadata);
|
||||
|
||||
const { teamId, userId, parentId, assignAllTeamMembers, rescheduleWithSameRoundRobinHost } =
|
||||
databaseEventType;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { ownerId, users, ...rest } = this.outputEventTypesService.getResponseEventType(
|
||||
0,
|
||||
@@ -117,6 +126,7 @@ export class OutputOrganizationsEventTypesService {
|
||||
? this.getResponseSchedulingType(databaseEventType.schedulingType)
|
||||
: databaseEventType.schedulingType,
|
||||
assignAllTeamMembers: teamId ? assignAllTeamMembers : undefined,
|
||||
emailSettings,
|
||||
team: {
|
||||
id: teamId,
|
||||
name: databaseEventType?.team?.name,
|
||||
@@ -128,6 +138,7 @@ export class OutputOrganizationsEventTypesService {
|
||||
darkBrandColor: databaseEventType?.team?.darkBrandColor,
|
||||
theme: databaseEventType?.team?.theme,
|
||||
},
|
||||
rescheduleWithSameRoundRobinHost,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -194,6 +205,23 @@ export class OutputOrganizationsEventTypesService {
|
||||
|
||||
return transformedHosts;
|
||||
}
|
||||
|
||||
private transformEmailSettings(metadata: EventTypeMetadata) {
|
||||
if (!metadata?.disableStandardEmails?.all) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { attendee, host } = metadata.disableStandardEmails.all;
|
||||
|
||||
if (attendee !== undefined || host !== undefined) {
|
||||
return {
|
||||
disableEmailsToAttendees: attendee ?? false,
|
||||
disableEmailsToHosts: host ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getPriorityLabel(priority: number): keyof typeof HostPriority {
|
||||
|
||||
@@ -46,8 +46,8 @@ import { OrganizationsStripeService } from "@/modules/organizations/stripe/servi
|
||||
import { OrganizationsTeamsController } from "@/modules/organizations/teams/index/organizations-teams.controller";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
|
||||
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
|
||||
import { OrganizationsTeamsInviteController } from "@/modules/organizations/teams/invite/organizations-teams-invite.controller";
|
||||
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
|
||||
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
|
||||
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
|
||||
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
|
||||
@@ -76,7 +76,8 @@ import { UsersModule } from "@/modules/users/users.module";
|
||||
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
|
||||
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
||||
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
|
||||
import { TeamWorkflowsService } from "@/modules/workflows/services/team-workflows.service";
|
||||
import { TeamEventTypeWorkflowsService } from "@/modules/workflows/services/team-event-type-workflows.service";
|
||||
import { TeamRoutingFormWorkflowsService } from "@/modules/workflows/services/team-routing-form-workflows.service";
|
||||
import { WorkflowsInputService } from "@/modules/workflows/services/workflows.input.service";
|
||||
import { WorkflowsOutputService } from "@/modules/workflows/services/workflows.output.service";
|
||||
import { WorkflowsRepository } from "@/modules/workflows/workflows.repository";
|
||||
@@ -145,7 +146,8 @@ import { Module } from "@nestjs/common";
|
||||
TokensRepository,
|
||||
TeamsVerifiedResourcesRepository,
|
||||
WorkflowsRepository,
|
||||
TeamWorkflowsService,
|
||||
TeamEventTypeWorkflowsService,
|
||||
TeamRoutingFormWorkflowsService,
|
||||
WorkflowsInputService,
|
||||
WorkflowsOutputService,
|
||||
TeamsSchedulesService,
|
||||
|
||||
+540
-34
@@ -4,22 +4,44 @@ import { AppModule } from "@/app.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { CreateWorkflowDto } from "@/modules/workflows/inputs/create-workflow.input";
|
||||
import {
|
||||
CreateEventTypeWorkflowDto,
|
||||
WorkflowActivationDto,
|
||||
} from "@/modules/workflows/inputs/create-event-type-workflow.input";
|
||||
import {
|
||||
CreateFormWorkflowDto,
|
||||
WorkflowFormActivationDto,
|
||||
} from "@/modules/workflows/inputs/create-form-workflow";
|
||||
import {
|
||||
ATTENDEE,
|
||||
REMINDER,
|
||||
PHONE_NUMBER,
|
||||
EMAIL,
|
||||
WorkflowEmailAttendeeStepDto,
|
||||
WorkflowEmailAddressStepDto,
|
||||
UpdateEmailAddressWorkflowStepDto,
|
||||
UpdatePhoneWhatsAppNumberWorkflowStepDto,
|
||||
} from "@/modules/workflows/inputs/workflow-step.input";
|
||||
import {
|
||||
AFTER_EVENT,
|
||||
BEFORE_EVENT,
|
||||
DAY,
|
||||
FORM_SUBMITTED,
|
||||
FORM_SUBMITTED_NO_EVENT,
|
||||
OnAfterEventTriggerDto,
|
||||
OnBeforeEventTriggerDto,
|
||||
OnFormSubmittedNoEventTriggerDto,
|
||||
OnFormSubmittedTriggerDto,
|
||||
} from "@/modules/workflows/inputs/workflow-trigger.input";
|
||||
import {
|
||||
GetEventTypeWorkflowOutput,
|
||||
GetEventTypeWorkflowsOutput,
|
||||
} from "@/modules/workflows/outputs/event-type-workflow.output";
|
||||
// Adjust path if needed
|
||||
import { GetWorkflowOutput, GetWorkflowsOutput } from "@/modules/workflows/outputs/workflow.output";
|
||||
import {
|
||||
GetRoutingFormWorkflowOutput,
|
||||
GetRoutingFormWorkflowsOutput,
|
||||
} from "@/modules/workflows/outputs/routing-form-workflow.output";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
@@ -51,14 +73,21 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
let org: Team;
|
||||
let orgTeam: Team;
|
||||
let createdWorkflowId: number;
|
||||
let createdFormWorkflowId: number;
|
||||
const authEmail = `org-teams-workflows-user-${randomString()}@example.com`;
|
||||
let user: User;
|
||||
let apiKeyString: string;
|
||||
let verifiedPhoneId: number;
|
||||
let verifiedPhoneId2: number;
|
||||
let verifiedEmailId: number;
|
||||
let createdWorkflow: GetWorkflowOutput["data"];
|
||||
let verifiedEmailId2: number;
|
||||
let createdWorkflow: GetEventTypeWorkflowOutput["data"];
|
||||
let createdFormWorkflow: GetRoutingFormWorkflowOutput["data"];
|
||||
|
||||
let sampleCreateWorkflowDto: CreateWorkflowDto = {
|
||||
const emailToVerify = `org-teams-workflows-team-${randomString()}@example.com`;
|
||||
const phoneToVerify = `+37255556666`;
|
||||
|
||||
let sampleCreateEventTypeWorkflowDto = {
|
||||
name: `E2E Test Workflow ${randomString()}`,
|
||||
activation: {
|
||||
isActiveOnAllEventTypes: true,
|
||||
@@ -72,6 +101,18 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
},
|
||||
},
|
||||
steps: [],
|
||||
} as unknown as CreateEventTypeWorkflowDto;
|
||||
|
||||
let sampleCreateWorkflowRoutingFormDto: CreateFormWorkflowDto = {
|
||||
name: `E2E Test Workflow ${randomString()}`,
|
||||
activation: {
|
||||
activeOnRoutingFormIds: [],
|
||||
isActiveOnAllRoutingForms: true,
|
||||
},
|
||||
trigger: {
|
||||
type: FORM_SUBMITTED,
|
||||
},
|
||||
steps: [],
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -149,15 +190,29 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
phoneNumber: "+37255555555",
|
||||
team: { connect: { id: orgTeam.id } },
|
||||
});
|
||||
const verifiedPhone2 = await verifiedResourcesRepositoryFixtures.createPhone({
|
||||
user: { connect: { id: user.id } },
|
||||
phoneNumber: phoneToVerify,
|
||||
team: { connect: { id: orgTeam.id } },
|
||||
});
|
||||
const verifiedEmail = await verifiedResourcesRepositoryFixtures.createEmail({
|
||||
user: { connect: { id: user.id } },
|
||||
email: authEmail,
|
||||
team: { connect: { id: orgTeam.id } },
|
||||
});
|
||||
verifiedEmailId = verifiedEmail.id;
|
||||
verifiedPhoneId = verifiedPhone.id;
|
||||
|
||||
sampleCreateWorkflowDto = {
|
||||
const verifiedEmail2 = await verifiedResourcesRepositoryFixtures.createEmail({
|
||||
user: { connect: { id: user.id } },
|
||||
email: emailToVerify,
|
||||
team: { connect: { id: orgTeam.id } },
|
||||
});
|
||||
verifiedEmailId = verifiedEmail.id;
|
||||
verifiedEmailId2 = verifiedEmail2.id;
|
||||
|
||||
verifiedPhoneId = verifiedPhone.id;
|
||||
verifiedPhoneId2 = verifiedPhone2.id;
|
||||
|
||||
sampleCreateEventTypeWorkflowDto = {
|
||||
name: `E2E Test Workflow ${randomString()}`,
|
||||
activation: {
|
||||
isActiveOnAllEventTypes: true,
|
||||
@@ -208,6 +263,43 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
html: "<p>Reminder for your event {EVENT_NAME}.</p>",
|
||||
},
|
||||
},
|
||||
{
|
||||
stepNumber: 4,
|
||||
action: "sms_attendee",
|
||||
recipient: PHONE_NUMBER,
|
||||
template: REMINDER,
|
||||
phoneRequired: true,
|
||||
sender: "CalcomE2EStep4",
|
||||
message: {
|
||||
subject: "Upcoming: {EVENT_NAME}",
|
||||
text: "Reminder for your event {EVENT_NAME}.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sampleCreateWorkflowRoutingFormDto = {
|
||||
name: `E2E Test Form Workflow ${randomString()}`,
|
||||
activation: {
|
||||
isActiveOnAllRoutingForms: true,
|
||||
activeOnRoutingFormIds: [],
|
||||
},
|
||||
trigger: {
|
||||
type: FORM_SUBMITTED,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
stepNumber: 1,
|
||||
action: "email_attendee",
|
||||
recipient: ATTENDEE,
|
||||
template: REMINDER,
|
||||
sender: "CalcomE2EStep1",
|
||||
includeCalendarEvent: true,
|
||||
message: {
|
||||
subject: "Upcoming: {EVENT_NAME}",
|
||||
html: "<p>Reminder for your event {EVENT_NAME}.</p>",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
basePath = `/v2/organizations/${org.id}/teams/${orgTeam.id}/workflows`;
|
||||
@@ -220,7 +312,9 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
if (createdWorkflowId) {
|
||||
try {
|
||||
await workflowsRepositoryFixture.delete(createdWorkflowId);
|
||||
} catch (error) {}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
@@ -235,18 +329,227 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(basePath)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send(sampleCreateWorkflowDto)
|
||||
.send(sampleCreateEventTypeWorkflowDto)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: GetWorkflowOutput = response.body;
|
||||
const responseBody: GetEventTypeWorkflowOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(sampleCreateWorkflowDto.name);
|
||||
expect(responseBody.data.activation.isActiveOnAllEventTypes).toEqual(
|
||||
sampleCreateWorkflowDto.activation.isActiveOnAllEventTypes
|
||||
expect(responseBody.data.activation).toBeDefined();
|
||||
|
||||
expect(responseBody.data.name).toEqual(sampleCreateEventTypeWorkflowDto.name);
|
||||
if (responseBody.data.activation instanceof WorkflowActivationDto) {
|
||||
expect(responseBody.data.activation.isActiveOnAllEventTypes).toEqual(
|
||||
sampleCreateEventTypeWorkflowDto.activation.isActiveOnAllEventTypes
|
||||
);
|
||||
}
|
||||
if (
|
||||
responseBody.data.activation instanceof WorkflowFormActivationDto &&
|
||||
sampleCreateEventTypeWorkflowDto.activation instanceof WorkflowFormActivationDto
|
||||
) {
|
||||
expect(responseBody.data.activation.isActiveOnAllRoutingForms).toEqual(
|
||||
sampleCreateEventTypeWorkflowDto.activation.isActiveOnAllRoutingForms
|
||||
);
|
||||
}
|
||||
|
||||
expect(responseBody.data.trigger.type).toEqual(sampleCreateEventTypeWorkflowDto.trigger.type);
|
||||
expect(responseBody.data.steps).toHaveLength(sampleCreateEventTypeWorkflowDto.steps.length);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.id).toBeDefined();
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 2)?.id).toBeDefined();
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.id).toBeDefined();
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 4)?.id).toBeDefined();
|
||||
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.sender).toEqual(
|
||||
"CalcomE2EStep1"
|
||||
);
|
||||
expect(responseBody.data.trigger.type).toEqual(sampleCreateWorkflowDto.trigger.type);
|
||||
expect(responseBody.data.steps).toHaveLength(sampleCreateWorkflowDto.steps.length);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.includeCalendarEvent).toEqual(
|
||||
true
|
||||
);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 2)?.sender).toEqual(
|
||||
"CalcomE2EStep2"
|
||||
);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 2)?.phone).toEqual(
|
||||
"+37255555555"
|
||||
);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 4)?.phoneRequired).toEqual(true);
|
||||
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.email).toEqual(authEmail);
|
||||
const trigger = sampleCreateEventTypeWorkflowDto.trigger as OnBeforeEventTriggerDto;
|
||||
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
|
||||
expect(responseBody.data.trigger?.offset?.unit).toEqual(trigger.offset.unit);
|
||||
|
||||
createdWorkflowId = responseBody.data.id;
|
||||
createdWorkflow = responseBody.data;
|
||||
expect(responseBody.data.type).toEqual("event-type");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not create a new routing form workflow with trigger not FORM_SUBMITTED", async () => {
|
||||
const invalidWorkflow = structuredClone(
|
||||
sampleCreateWorkflowRoutingFormDto
|
||||
) as unknown as CreateEventTypeWorkflowDto;
|
||||
invalidWorkflow.trigger.type = AFTER_EVENT;
|
||||
return request(app.getHttpServer())
|
||||
.post(`${basePath}/routing-form`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send(invalidWorkflow)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should not create a new routing form workflow with not allowed actions", async () => {
|
||||
// force impossible step to test validation, should fail with 400
|
||||
const invalidWorkflow = structuredClone(
|
||||
sampleCreateWorkflowRoutingFormDto
|
||||
) as unknown as CreateEventTypeWorkflowDto;
|
||||
invalidWorkflow.steps = [
|
||||
{
|
||||
stepNumber: 1,
|
||||
action: "sms_number",
|
||||
recipient: PHONE_NUMBER,
|
||||
template: REMINDER,
|
||||
verifiedPhoneId: verifiedPhoneId,
|
||||
sender: "CalcomE2EStep2",
|
||||
message: {
|
||||
subject: "Upcoming: {EVENT_NAME}",
|
||||
text: "Reminder for your event {EVENT_NAME}.",
|
||||
},
|
||||
} as unknown as WorkflowEmailAddressStepDto,
|
||||
];
|
||||
return request(app.getHttpServer())
|
||||
.post(`${basePath}/routing-form`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send(invalidWorkflow)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should create a new routing form workflow with allowed actions", async () => {
|
||||
const validWorkflow = structuredClone(
|
||||
sampleCreateWorkflowRoutingFormDto
|
||||
) as unknown as CreateEventTypeWorkflowDto;
|
||||
validWorkflow.steps = [
|
||||
{
|
||||
stepNumber: 1,
|
||||
action: "email_attendee",
|
||||
recipient: ATTENDEE,
|
||||
template: REMINDER,
|
||||
sender: "CalcomE2EStep1",
|
||||
includeCalendarEvent: true,
|
||||
message: {
|
||||
subject: "Upcoming: {EVENT_NAME}",
|
||||
html: "<p>Reminder for your event {EVENT_NAME}.</p>",
|
||||
},
|
||||
},
|
||||
];
|
||||
return request(app.getHttpServer())
|
||||
.post(`${basePath}/routing-form`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send(validWorkflow)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: GetRoutingFormWorkflowOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(sampleCreateWorkflowRoutingFormDto.name);
|
||||
expect(responseBody.data.type).toEqual("routing-form");
|
||||
|
||||
if (responseBody.data.activation instanceof WorkflowFormActivationDto) {
|
||||
expect(responseBody.data.activation.isActiveOnAllRoutingForms).toEqual(
|
||||
sampleCreateWorkflowRoutingFormDto.activation.isActiveOnAllRoutingForms
|
||||
);
|
||||
}
|
||||
|
||||
expect(responseBody.data.trigger.type).toEqual(sampleCreateWorkflowRoutingFormDto.trigger.type);
|
||||
expect(responseBody.data.steps).toHaveLength(sampleCreateWorkflowRoutingFormDto.steps.length);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.id).toBeDefined();
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.sender).toEqual(
|
||||
"CalcomE2EStep1"
|
||||
);
|
||||
|
||||
const trigger = sampleCreateWorkflowRoutingFormDto.trigger as OnFormSubmittedTriggerDto;
|
||||
expect(responseBody.data.trigger?.type).toEqual(trigger.type);
|
||||
|
||||
createdFormWorkflowId = responseBody.data.id;
|
||||
createdFormWorkflow = responseBody.data;
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a new routing form workflow with allowed actions and offset trigger", async () => {
|
||||
const validWorkflow = structuredClone(
|
||||
sampleCreateWorkflowRoutingFormDto
|
||||
) as unknown as CreateFormWorkflowDto;
|
||||
validWorkflow.steps = [
|
||||
{
|
||||
stepNumber: 1,
|
||||
action: "email_attendee",
|
||||
recipient: ATTENDEE,
|
||||
template: REMINDER,
|
||||
sender: "CalcomE2EStep1",
|
||||
includeCalendarEvent: true,
|
||||
message: {
|
||||
subject: "Upcoming: {EVENT_NAME}",
|
||||
html: "<p>Reminder for your event {EVENT_NAME}.</p>",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
validWorkflow.trigger = {
|
||||
type: FORM_SUBMITTED_NO_EVENT,
|
||||
offset: {
|
||||
value: 1,
|
||||
unit: DAY,
|
||||
},
|
||||
};
|
||||
return request(app.getHttpServer())
|
||||
.post(`${basePath}/routing-form`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send(validWorkflow)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: GetRoutingFormWorkflowOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(sampleCreateWorkflowRoutingFormDto.name);
|
||||
expect(responseBody.data.type).toEqual("routing-form");
|
||||
|
||||
if (responseBody.data.activation instanceof WorkflowFormActivationDto) {
|
||||
expect(responseBody.data.activation.isActiveOnAllRoutingForms).toEqual(
|
||||
sampleCreateWorkflowRoutingFormDto.activation.isActiveOnAllRoutingForms
|
||||
);
|
||||
}
|
||||
|
||||
expect(responseBody.data.trigger.type).toEqual(validWorkflow.trigger.type);
|
||||
expect(responseBody.data.steps).toHaveLength(sampleCreateWorkflowRoutingFormDto.steps.length);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.id).toBeDefined();
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.sender).toEqual(
|
||||
"CalcomE2EStep1"
|
||||
);
|
||||
|
||||
const trigger = validWorkflow.trigger as OnFormSubmittedNoEventTriggerDto;
|
||||
expect(responseBody.data.trigger?.type).toEqual(trigger.type);
|
||||
expect(responseBody.data.trigger?.offset?.unit).toEqual(trigger.offset.unit);
|
||||
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a new workflow", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(basePath)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({ ...sampleCreateEventTypeWorkflowDto, type: undefined })
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: GetEventTypeWorkflowOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.name).toEqual(sampleCreateEventTypeWorkflowDto.name);
|
||||
if (responseBody.data.activation instanceof WorkflowActivationDto) {
|
||||
expect(responseBody.data.activation.isActiveOnAllEventTypes).toEqual(
|
||||
sampleCreateEventTypeWorkflowDto.activation.isActiveOnAllEventTypes
|
||||
);
|
||||
}
|
||||
|
||||
expect(responseBody.data.trigger.type).toEqual(sampleCreateEventTypeWorkflowDto.trigger.type);
|
||||
expect(responseBody.data.steps).toHaveLength(sampleCreateEventTypeWorkflowDto.steps.length);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 1)?.id).toBeDefined();
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 2)?.id).toBeDefined();
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.id).toBeDefined();
|
||||
@@ -260,7 +563,7 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
"+37255555555"
|
||||
);
|
||||
expect(responseBody.data.steps.find((step) => step.stepNumber === 3)?.email).toEqual(authEmail);
|
||||
const trigger = sampleCreateWorkflowDto.trigger as OnBeforeEventTriggerDto;
|
||||
const trigger = sampleCreateEventTypeWorkflowDto.trigger as OnBeforeEventTriggerDto;
|
||||
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
|
||||
expect(responseBody.data.trigger?.offset?.unit).toEqual(trigger.offset.unit);
|
||||
|
||||
@@ -269,12 +572,23 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should not create a new workflow", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(basePath)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
...sampleCreateEventTypeWorkflowDto,
|
||||
trigger: { ...sampleCreateEventTypeWorkflowDto, type: "formSubmitted" },
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return 401 if not authenticated", async () => {
|
||||
return request(app.getHttpServer()).post(basePath).send(sampleCreateWorkflowDto).expect(401);
|
||||
return request(app.getHttpServer()).post(basePath).send(sampleCreateEventTypeWorkflowDto).expect(401);
|
||||
});
|
||||
|
||||
it("should return 400 for invalid data (e.g. missing name)", async () => {
|
||||
const invalidDto = { ...sampleCreateWorkflowDto, name: undefined };
|
||||
const invalidDto = { ...sampleCreateEventTypeWorkflowDto, name: undefined };
|
||||
return request(app.getHttpServer())
|
||||
.post(basePath)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
@@ -284,17 +598,33 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
});
|
||||
|
||||
describe(`GET ${basePath}`, () => {
|
||||
it("should get a list of workflows for the team", async () => {
|
||||
it("should get a list of event-type workflows for the team", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`${basePath}?skip=0&take=10`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetWorkflowsOutput = response.body;
|
||||
const responseBody: GetEventTypeWorkflowsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeInstanceOf(Array);
|
||||
expect(responseBody.data.length).toBeGreaterThanOrEqual(1);
|
||||
expect(responseBody.data.some((wf) => wf.id === createdWorkflowId)).toBe(true);
|
||||
expect(responseBody.data.every((wf) => wf.type === "event-type")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get a list of routing-form workflows for the team", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`${basePath}/routing-form?skip=0&take=10`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetRoutingFormWorkflowsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeInstanceOf(Array);
|
||||
expect(responseBody.data.length).toBeGreaterThanOrEqual(1);
|
||||
expect(responseBody.data.some((wf) => wf.id === createdFormWorkflowId)).toBe(true);
|
||||
expect(responseBody.data.every((wf) => wf.type === "routing-form")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -311,10 +641,26 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetWorkflowOutput = response.body;
|
||||
const responseBody: GetEventTypeWorkflowOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.id).toEqual(createdWorkflowId);
|
||||
expect(responseBody.data.type).toEqual("event-type");
|
||||
});
|
||||
});
|
||||
|
||||
it("should get a specific routing-form workflow by ID", async () => {
|
||||
expect(createdWorkflowId).toBeDefined();
|
||||
return request(app.getHttpServer())
|
||||
.get(`${basePath}/${createdFormWorkflowId}/routing-form`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetEventTypeWorkflowOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.id).toEqual(createdFormWorkflowId);
|
||||
expect(responseBody.data.type).toEqual("routing-form");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -334,10 +680,12 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
describe(`PATCH ${basePath}/:workflowId`, () => {
|
||||
const updatedName = `Updated Workflow Name ${randomString()}`;
|
||||
|
||||
it("should update an existing workflow, update the first step and discard other steps", async () => {
|
||||
const step1 = createdWorkflow.steps.find((step) => step.stepNumber === 1);
|
||||
expect(step1).toBeDefined();
|
||||
const partialUpdateDto: Partial<CreateWorkflowDto> = {
|
||||
it("should update an existing workflow, update the first and second step and discard other steps", async () => {
|
||||
const step2 = createdWorkflow.steps.find((step) => step.stepNumber === 2);
|
||||
expect(step2).toBeDefined();
|
||||
const step3 = createdWorkflow.steps.find((step) => step.stepNumber === 3);
|
||||
expect(step3).toBeDefined();
|
||||
const partialUpdateDto: Partial<CreateEventTypeWorkflowDto> = {
|
||||
name: updatedName,
|
||||
trigger: {
|
||||
type: "afterEvent",
|
||||
@@ -346,7 +694,38 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
value: 10,
|
||||
},
|
||||
},
|
||||
steps: step1 ? [{ ...step1, sender: "updatedSender" } as WorkflowEmailAttendeeStepDto] : [],
|
||||
steps:
|
||||
step3 && step2
|
||||
? [
|
||||
{
|
||||
stepNumber: 1,
|
||||
id: step3.id,
|
||||
action: "email_address",
|
||||
recipient: EMAIL,
|
||||
template: REMINDER,
|
||||
verifiedEmailId: verifiedEmailId2,
|
||||
sender: "updatedSender",
|
||||
includeCalendarEvent: false,
|
||||
message: {
|
||||
subject: "Update Upcoming: {EVENT_NAME}",
|
||||
html: "<p>Update Reminder for your event {EVENT_NAME}.</p>",
|
||||
},
|
||||
} as UpdateEmailAddressWorkflowStepDto,
|
||||
{
|
||||
stepNumber: 2,
|
||||
id: step2.id,
|
||||
action: "whatsapp_number",
|
||||
recipient: PHONE_NUMBER,
|
||||
template: REMINDER,
|
||||
verifiedPhoneId: verifiedPhoneId2,
|
||||
sender: "updatedSender",
|
||||
message: {
|
||||
subject: "Update Upcoming: {EVENT_NAME}",
|
||||
text: "Update Reminder for your event {EVENT_NAME}.",
|
||||
},
|
||||
} as UpdatePhoneWhatsAppNumberWorkflowStepDto,
|
||||
]
|
||||
: [],
|
||||
};
|
||||
expect(createdWorkflowId).toBeDefined();
|
||||
expect(createdWorkflow).toBeDefined();
|
||||
@@ -356,14 +735,32 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
.send(partialUpdateDto)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetWorkflowOutput = response.body;
|
||||
const responseBody: GetEventTypeWorkflowOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.id).toEqual(createdWorkflowId);
|
||||
expect(responseBody.data.name).toEqual(updatedName);
|
||||
step1 && expect(responseBody.data.steps[0].id).toEqual(step1.id);
|
||||
expect(responseBody.data.steps[0].sender).toEqual("updatedSender");
|
||||
expect(responseBody.data.steps[1]?.id).toBeUndefined();
|
||||
if (step3) {
|
||||
const newStep3 = responseBody.data.steps.find((step) => step.id === step3.id);
|
||||
expect(newStep3).toBeDefined();
|
||||
if (newStep3) {
|
||||
expect(newStep3.sender).toEqual("updatedSender");
|
||||
expect(newStep3.email).toEqual(emailToVerify);
|
||||
expect(newStep3.includeCalendarEvent).toEqual(false);
|
||||
}
|
||||
}
|
||||
if (step2) {
|
||||
const newStep2 = responseBody.data.steps.find((step) => step.id === step2.id);
|
||||
expect(newStep2).toBeDefined();
|
||||
if (newStep2) {
|
||||
expect(responseBody.data.steps[1].sender).toEqual("updatedSender");
|
||||
expect(responseBody.data.steps[1].phone).toEqual(phoneToVerify);
|
||||
}
|
||||
}
|
||||
|
||||
// we updated 2 steps, third one should have been discarded
|
||||
expect(responseBody.data.steps[2]?.id).toBeUndefined();
|
||||
|
||||
const trigger = partialUpdateDto.trigger as OnAfterEventTriggerDto;
|
||||
expect(responseBody.data.trigger?.type).toEqual(trigger.type);
|
||||
expect(responseBody.data.trigger?.offset?.value).toEqual(trigger.offset.value);
|
||||
@@ -371,8 +768,107 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should not update an existing event-type workflow, trying to use form workflow trigger", async () => {
|
||||
const step2 = createdWorkflow.steps.find((step) => step.stepNumber === 2);
|
||||
expect(step2).toBeDefined();
|
||||
const step3 = createdWorkflow.steps.find((step) => step.stepNumber === 3);
|
||||
expect(step3).toBeDefined();
|
||||
const partialUpdateDto = {
|
||||
name: updatedName,
|
||||
trigger: {
|
||||
type: "formSubmitted",
|
||||
offset: {
|
||||
unit: "minute",
|
||||
value: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(createdWorkflowId).toBeDefined();
|
||||
expect(createdWorkflow).toBeDefined();
|
||||
return request(app.getHttpServer())
|
||||
.patch(`${basePath}/${createdWorkflowId}`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send(partialUpdateDto)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should not update an existing event-type workflow, trying to use routing-form workflow endpoint", async () => {
|
||||
const partialUpdateDto = {
|
||||
name: updatedName,
|
||||
};
|
||||
|
||||
expect(createdWorkflowId).toBeDefined();
|
||||
expect(createdWorkflow).toBeDefined();
|
||||
return request(app.getHttpServer())
|
||||
.patch(`${basePath}/${createdWorkflowId}/routing-form`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send(partialUpdateDto)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should update an existing routing form workflow, update the first step and discard any other steps", async () => {
|
||||
const step1 = createdFormWorkflow.steps.find((step) => step.stepNumber === 1);
|
||||
expect(step1).toBeDefined();
|
||||
|
||||
const partialUpdateDto: Partial<CreateFormWorkflowDto> = {
|
||||
name: updatedName,
|
||||
trigger: {
|
||||
type: "formSubmitted",
|
||||
},
|
||||
steps: step1
|
||||
? [
|
||||
{
|
||||
stepNumber: 1,
|
||||
id: step1.id,
|
||||
action: "email_address",
|
||||
recipient: EMAIL,
|
||||
template: REMINDER,
|
||||
verifiedEmailId: verifiedEmailId2,
|
||||
sender: "updatedSender",
|
||||
includeCalendarEvent: true,
|
||||
message: {
|
||||
subject: "Update Upcoming: {EVENT_NAME}",
|
||||
html: "<p>Update Reminder for your event {EVENT_NAME}.</p>",
|
||||
},
|
||||
} as UpdateEmailAddressWorkflowStepDto,
|
||||
]
|
||||
: [],
|
||||
};
|
||||
expect(createdFormWorkflowId).toBeDefined();
|
||||
expect(createdFormWorkflow).toBeDefined();
|
||||
return request(app.getHttpServer())
|
||||
.patch(`${basePath}/${createdFormWorkflowId}/routing-form`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send(partialUpdateDto)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetRoutingFormWorkflowOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.id).toEqual(createdFormWorkflowId);
|
||||
expect(responseBody.data.name).toEqual(updatedName);
|
||||
expect(responseBody.data.activation).toBeDefined();
|
||||
if (step1) {
|
||||
const newStep1 = responseBody.data.steps.find((step) => step.id === step1.id);
|
||||
expect(newStep1).toBeDefined();
|
||||
if (newStep1) {
|
||||
expect(newStep1.sender).toEqual("updatedSender");
|
||||
expect(newStep1.email).toEqual(emailToVerify);
|
||||
expect(newStep1.includeCalendarEvent).toEqual(true);
|
||||
}
|
||||
}
|
||||
|
||||
// we updated 1 steps, no more steps should be defined
|
||||
expect(responseBody.data.steps[1]?.id).toBeUndefined();
|
||||
const trigger = partialUpdateDto.trigger as OnFormSubmittedTriggerDto;
|
||||
expect(responseBody.data.trigger?.type).toEqual(trigger.type);
|
||||
expect(responseBody.data.type).toEqual("routing-form");
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 404 for updating a non-existent workflow ID", async () => {
|
||||
const partialUpdateDto: Partial<CreateWorkflowDto> = {
|
||||
const partialUpdateDto: Partial<CreateEventTypeWorkflowDto> = {
|
||||
name: updatedName,
|
||||
steps: [{ ...createdWorkflow.steps[0], sender: "updatedSender" } as WorkflowEmailAttendeeStepDto],
|
||||
};
|
||||
@@ -384,7 +880,7 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
});
|
||||
|
||||
it("should return 401 if not authenticated", async () => {
|
||||
const partialUpdateDto: Partial<CreateWorkflowDto> = {
|
||||
const partialUpdateDto: Partial<CreateEventTypeWorkflowDto> = {
|
||||
name: updatedName,
|
||||
steps: [{ ...createdWorkflow.steps[0], sender: "updatedSender" } as WorkflowEmailAttendeeStepDto],
|
||||
};
|
||||
@@ -403,11 +899,11 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(basePath)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({ ...sampleCreateWorkflowDto, name: `Workflow To Delete ${randomString()}` });
|
||||
.send({ ...sampleCreateEventTypeWorkflowDto, name: `Workflow To Delete ${randomString()}` });
|
||||
workflowToDeleteId = res.body.data.id;
|
||||
});
|
||||
|
||||
it("should delete an existing workflow", async () => {
|
||||
it("should delete an existing event-type workflow", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`${basePath}/${workflowToDeleteId}`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
@@ -417,6 +913,16 @@ describe("OrganizationsTeamsWorkflowsController (E2E)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete an existing routing-form workflow", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`${basePath}/${createdFormWorkflowId}/routing-form`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 404 when trying to delete a non-existent workflow ID", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`${basePath}/999999`)
|
||||
|
||||
+120
-22
@@ -13,12 +13,23 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { IsWorkflowInTeam } from "@/modules/auth/guards/workflows/is-workflow-in-team";
|
||||
import { IsEventTypeWorkflowInTeam } from "@/modules/auth/guards/workflows/is-event-type-workflow-in-team";
|
||||
import { IsRoutingFormWorkflowInTeam } from "@/modules/auth/guards/workflows/is-routing-form-workflow-in-team";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { CreateWorkflowDto } from "@/modules/workflows/inputs/create-workflow.input";
|
||||
import { UpdateWorkflowDto } from "@/modules/workflows/inputs/update-workflow.input";
|
||||
import { GetWorkflowOutput, GetWorkflowsOutput } from "@/modules/workflows/outputs/workflow.output";
|
||||
import { TeamWorkflowsService } from "@/modules/workflows/services/team-workflows.service";
|
||||
import { CreateEventTypeWorkflowDto } from "@/modules/workflows/inputs/create-event-type-workflow.input";
|
||||
import { CreateFormWorkflowDto } from "@/modules/workflows/inputs/create-form-workflow";
|
||||
import { UpdateEventTypeWorkflowDto } from "@/modules/workflows/inputs/update-event-type-workflow.input";
|
||||
import { UpdateFormWorkflowDto } from "@/modules/workflows/inputs/update-form-workflow.input";
|
||||
import {
|
||||
GetEventTypeWorkflowsOutput,
|
||||
GetEventTypeWorkflowOutput,
|
||||
} from "@/modules/workflows/outputs/event-type-workflow.output";
|
||||
import {
|
||||
GetRoutingFormWorkflowOutput,
|
||||
GetRoutingFormWorkflowsOutput,
|
||||
} from "@/modules/workflows/outputs/routing-form-workflow.output";
|
||||
import { TeamEventTypeWorkflowsService } from "@/modules/workflows/services/team-event-type-workflows.service";
|
||||
import { TeamRoutingFormWorkflowsService } from "@/modules/workflows/services/team-routing-form-workflows.service";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@@ -46,7 +57,10 @@ import { SkipTakePagination } from "@calcom/platform-types";
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||
export class OrganizationTeamWorkflowsController {
|
||||
constructor(private readonly workflowsService: TeamWorkflowsService) {}
|
||||
constructor(
|
||||
private readonly eventTypeWorkflowsService: TeamEventTypeWorkflowsService,
|
||||
private readonly routingFormWorkflowsService: TeamRoutingFormWorkflowsService
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get organization team workflows" })
|
||||
@@ -56,43 +70,89 @@ export class OrganizationTeamWorkflowsController {
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<GetWorkflowsOutput> {
|
||||
): Promise<GetEventTypeWorkflowsOutput> {
|
||||
const { skip, take } = queryParams;
|
||||
|
||||
const workflows = await this.workflowsService.getTeamWorkflows(teamId, skip, take);
|
||||
const workflows = await this.eventTypeWorkflowsService.getEventTypeTeamWorkflows(teamId, skip, take);
|
||||
|
||||
return { data: workflows, status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Get("/routing-form")
|
||||
@ApiOperation({ summary: "Get organization team workflows" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
async getRoutingFormWorkflows(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<GetRoutingFormWorkflowsOutput> {
|
||||
const { skip, take } = queryParams;
|
||||
|
||||
const workflows = await this.routingFormWorkflowsService.getRoutingFormTeamWorkflows(teamId, skip, take);
|
||||
|
||||
return { data: workflows, status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Get("/:workflowId")
|
||||
@UseGuards(IsWorkflowInTeam)
|
||||
@UseGuards(IsEventTypeWorkflowInTeam)
|
||||
@ApiOperation({ summary: "Get organization team workflow" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
async getWorkflowById(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("workflowId", ParseIntPipe) workflowId: number
|
||||
): Promise<GetWorkflowOutput> {
|
||||
const workflow = await this.workflowsService.getTeamWorkflowById(teamId, workflowId);
|
||||
): Promise<GetEventTypeWorkflowOutput> {
|
||||
const workflow = await this.eventTypeWorkflowsService.getEventTypeTeamWorkflowById(teamId, workflowId);
|
||||
|
||||
return { data: workflow, status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Get("/:workflowId/routing-form")
|
||||
@UseGuards(IsRoutingFormWorkflowInTeam)
|
||||
@ApiOperation({ summary: "Get organization team workflow" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
async getRoutingFormWorkflowById(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("workflowId", ParseIntPipe) workflowId: number
|
||||
): Promise<GetRoutingFormWorkflowOutput> {
|
||||
const workflow = await this.routingFormWorkflowsService.getRoutingFormTeamWorkflowById(
|
||||
teamId,
|
||||
workflowId
|
||||
);
|
||||
|
||||
return { data: workflow, status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Post("/")
|
||||
@ApiOperation({ summary: "Create organization team workflow" })
|
||||
@ApiOperation({ summary: "Create organization team workflow for event-types" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
async createWorkflow(
|
||||
async createEventTypeWorkflow(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Body() data: CreateWorkflowDto
|
||||
): Promise<GetWorkflowOutput> {
|
||||
const workflow = await this.workflowsService.createTeamWorkflow(user, teamId, data);
|
||||
@Body() data: CreateEventTypeWorkflowDto
|
||||
): Promise<GetEventTypeWorkflowOutput> {
|
||||
const workflow = await this.eventTypeWorkflowsService.createEventTypeTeamWorkflow(user, teamId, data);
|
||||
return { data: workflow, status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Post("/routing-form")
|
||||
@ApiOperation({ summary: "Create organization team workflow for routing-forms" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
async createFormWorkflow(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Body() data: CreateFormWorkflowDto
|
||||
): Promise<GetRoutingFormWorkflowOutput> {
|
||||
const workflow = await this.routingFormWorkflowsService.createFormTeamWorkflow(user, teamId, data);
|
||||
return { data: workflow, status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Patch("/:workflowId")
|
||||
@UseGuards(IsWorkflowInTeam)
|
||||
@UseGuards(IsEventTypeWorkflowInTeam)
|
||||
@ApiOperation({ summary: "Update organization team workflow" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@@ -100,14 +160,39 @@ export class OrganizationTeamWorkflowsController {
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("workflowId", ParseIntPipe) workflowId: number,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() data: UpdateWorkflowDto
|
||||
): Promise<GetWorkflowOutput> {
|
||||
const workflow = await this.workflowsService.updateTeamWorkflow(user, teamId, workflowId, data);
|
||||
@Body() data: UpdateEventTypeWorkflowDto
|
||||
): Promise<GetEventTypeWorkflowOutput> {
|
||||
const workflow = await this.eventTypeWorkflowsService.updateEventTypeTeamWorkflow(
|
||||
user,
|
||||
teamId,
|
||||
workflowId,
|
||||
data
|
||||
);
|
||||
return { data: workflow, status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Patch("/:workflowId/routing-form")
|
||||
@UseGuards(IsRoutingFormWorkflowInTeam)
|
||||
@ApiOperation({ summary: "Update organization routing form team workflow" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
async updateRoutingFormWorkflow(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("workflowId", ParseIntPipe) workflowId: number,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() data: UpdateFormWorkflowDto
|
||||
): Promise<GetRoutingFormWorkflowOutput> {
|
||||
const workflow = await this.routingFormWorkflowsService.updateFormTeamWorkflow(
|
||||
user,
|
||||
teamId,
|
||||
workflowId,
|
||||
data
|
||||
);
|
||||
return { data: workflow, status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Delete("/:workflowId")
|
||||
@UseGuards(IsWorkflowInTeam)
|
||||
@UseGuards(IsEventTypeWorkflowInTeam)
|
||||
@ApiOperation({ summary: "Delete organization team workflow" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@@ -115,7 +200,20 @@ export class OrganizationTeamWorkflowsController {
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("workflowId") workflowId: number
|
||||
): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
await this.workflowsService.deleteTeamWorkflow(teamId, workflowId);
|
||||
await this.eventTypeWorkflowsService.deleteTeamEventTypeWorkflow(teamId, workflowId);
|
||||
return { status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Delete("/:workflowId/routing-form")
|
||||
@UseGuards(IsRoutingFormWorkflowInTeam)
|
||||
@ApiOperation({ summary: "Delete organization team routing-form workflow" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
async deleteRoutingFormWorkflow(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("workflowId") workflowId: number
|
||||
): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
await this.routingFormWorkflowsService.deleteTeamRoutingFormWorkflow(teamId, workflowId);
|
||||
return { status: SUCCESS_STATUS };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { GetUsersInput } from "@/modules/users/inputs/get-users.input";
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { ArrayMaxSize, ArrayNotEmpty, IsEmail, IsOptional } from "class-validator";
|
||||
|
||||
export class GetTeamMembershipsInput extends GetUsersInput {
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
export class GetTeamMembershipsInput extends SkipTakePagination {
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value == null) return undefined;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Locales } from "@/lib/enums/locales";
|
||||
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn, IsUrl, IsObject } from "class-validator";
|
||||
import { Transform, Type } from "class-transformer";
|
||||
import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn, IsUrl, IsObject, IsNumber } from "class-validator";
|
||||
|
||||
import { ValidateMetadata } from "@calcom/platform-types";
|
||||
|
||||
@@ -17,6 +17,8 @@ export class CreateManagedUserInput {
|
||||
@ApiProperty({ example: "Alice Smith", description: "Managed user's name is used in emails" })
|
||||
name!: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@IsIn([12, 24], { message: "timeFormat must be a number either 12 or 24" })
|
||||
@ApiPropertyOptional({ example: 12, enum: [12, 24], description: "Must be a number 12 or 24" })
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Locales } from "@/lib/enums/locales";
|
||||
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
|
||||
import { TimeFormat, WeekDay } from "@/modules/users/inputs/create-managed-user.input";
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform, Type } from "class-transformer";
|
||||
import { IsEnum, IsIn, IsNumber, IsObject, IsOptional, IsString, IsTimeZone, IsUrl } from "class-validator";
|
||||
|
||||
import { ValidateMetadata } from "@calcom/platform-types";
|
||||
@@ -17,6 +18,8 @@ export class UpdateManagedUserInput {
|
||||
@ApiPropertyOptional()
|
||||
name?: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@IsIn([12, 24])
|
||||
@ApiPropertyOptional({ example: 12, enum: [12, 24], description: "Must be 12 or 24" })
|
||||
|
||||
+20
-25
@@ -17,6 +17,7 @@ import {
|
||||
EMAIL_HOST,
|
||||
SMS_ATTENDEE,
|
||||
SMS_NUMBER,
|
||||
STEP_ACTIONS,
|
||||
WHATSAPP_ATTENDEE,
|
||||
WHATSAPP_NUMBER,
|
||||
WorkflowEmailAddressStepDto,
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
AFTER_EVENT,
|
||||
AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
|
||||
AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
BaseWorkflowTriggerDto,
|
||||
EventTypeWorkflowTriggerDto,
|
||||
BEFORE_EVENT,
|
||||
BOOKING_NO_SHOW_UPDATED,
|
||||
BOOKING_PAID,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
BOOKING_REJECTED,
|
||||
BOOKING_REQUESTED,
|
||||
EVENT_CANCELLED,
|
||||
EVENT_TYPE_WORKFLOW_TRIGGER_TYPES,
|
||||
NEW_EVENT,
|
||||
OnAfterCalVideoGuestsNoShowTriggerDto,
|
||||
OnAfterCalVideoHostsNoShowTriggerDto,
|
||||
@@ -70,26 +72,12 @@ export class WorkflowActivationDto {
|
||||
example: [698191],
|
||||
type: [Number],
|
||||
})
|
||||
@ValidateIf((o) => !Boolean(o.isActiveOnAllEventTypes))
|
||||
@ValidateIf((o) => !o.isActiveOnAllEventTypes)
|
||||
@IsOptional()
|
||||
@IsNumber({}, { each: true })
|
||||
activeOnEventTypeIds: number[] = [];
|
||||
}
|
||||
|
||||
export type TriggerDtoType =
|
||||
| OnAfterEventTriggerDto
|
||||
| OnBeforeEventTriggerDto
|
||||
| OnCreationTriggerDto
|
||||
| OnRescheduleTriggerDto
|
||||
| OnCancelTriggerDto
|
||||
| OnAfterCalVideoGuestsNoShowTriggerDto
|
||||
| OnRejectedTriggerDto
|
||||
| OnRequestedTriggerDto
|
||||
| OnPaymentInitiatedTriggerDto
|
||||
| OnPaidTriggerDto
|
||||
| OnNoShowUpdateTriggerDto
|
||||
| OnAfterCalVideoHostsNoShowTriggerDto;
|
||||
|
||||
@ApiExtraModels(
|
||||
OnBeforeEventTriggerDto,
|
||||
OnAfterEventTriggerDto,
|
||||
@@ -110,20 +98,24 @@ export type TriggerDtoType =
|
||||
WorkflowPhoneWhatsAppNumberStepDto,
|
||||
WorkflowPhoneNumberStepDto,
|
||||
WorkflowPhoneAttendeeStepDto,
|
||||
BaseWorkflowTriggerDto
|
||||
EventTypeWorkflowTriggerDto,
|
||||
WorkflowActivationDto
|
||||
)
|
||||
export class CreateWorkflowDto {
|
||||
export class CreateEventTypeWorkflowDto {
|
||||
@ApiProperty({ description: "Name of the workflow", example: "Platform Test Workflow" })
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: "Activation settings for the workflow", type: WorkflowActivationDto })
|
||||
@ApiProperty({
|
||||
description: "Activation settings for the workflow",
|
||||
type: WorkflowActivationDto,
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => WorkflowActivationDto)
|
||||
activation!: WorkflowActivationDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Trigger configuration for the workflow",
|
||||
description: `Trigger configuration for the event-type workflow, allowed triggers are ${EVENT_TYPE_WORKFLOW_TRIGGER_TYPES.toString()}`,
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(OnBeforeEventTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnAfterEventTriggerDto) },
|
||||
@@ -140,7 +132,8 @@ export class CreateWorkflowDto {
|
||||
],
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => BaseWorkflowTriggerDto, {
|
||||
@Type(() => EventTypeWorkflowTriggerDto, {
|
||||
keepDiscriminatorProperty: true,
|
||||
discriminator: {
|
||||
property: "type",
|
||||
subTypes: [
|
||||
@@ -170,11 +163,10 @@ export class CreateWorkflowDto {
|
||||
| OnPaidTriggerDto
|
||||
| OnPaymentInitiatedTriggerDto
|
||||
| OnNoShowUpdateTriggerDto
|
||||
| OnAfterCalVideoGuestsNoShowTriggerDto
|
||||
| OnAfterCalVideoHostsNoShowTriggerDto;
|
||||
| OnAfterCalVideoGuestsNoShowTriggerDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Steps to execute as part of the workflow",
|
||||
description: `Steps to execute as part of the event-type workflow, allowed steps are ${STEP_ACTIONS.toString()}`,
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(WorkflowEmailAddressStepDto) },
|
||||
{ $ref: getSchemaPath(WorkflowEmailAttendeeStepDto) },
|
||||
@@ -187,8 +179,11 @@ export class CreateWorkflowDto {
|
||||
type: "array",
|
||||
})
|
||||
@ValidateNested({ each: true })
|
||||
@ArrayMinSize(1, { message: "Your workflow must contain at least one step." })
|
||||
@ArrayMinSize(1, {
|
||||
message: `Your workflow must contain at least one allowed step. allowed steps are ${STEP_ACTIONS.toString()}`,
|
||||
})
|
||||
@Type(() => BaseWorkflowStepDto, {
|
||||
keepDiscriminatorProperty: true,
|
||||
discriminator: {
|
||||
property: "action",
|
||||
subTypes: [
|
||||
@@ -0,0 +1,106 @@
|
||||
import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsBoolean, ArrayMinSize, IsOptional, IsString, ValidateNested, ValidateIf } from "class-validator";
|
||||
|
||||
import {
|
||||
BaseFormWorkflowStepDto,
|
||||
EMAIL_ADDRESS,
|
||||
EMAIL_ATTENDEE,
|
||||
FORM_ALLOWED_STEP_ACTIONS,
|
||||
WorkflowEmailAddressStepDto,
|
||||
WorkflowEmailAttendeeStepDto,
|
||||
} from "./workflow-step.input";
|
||||
import {
|
||||
RoutingFormWorkflowTriggerDto,
|
||||
FORM_SUBMITTED,
|
||||
FORM_SUBMITTED_NO_EVENT,
|
||||
FORM_WORKFLOW_TRIGGER_TYPES,
|
||||
OnFormSubmittedNoEventTriggerDto,
|
||||
OnFormSubmittedTriggerDto,
|
||||
} from "./workflow-trigger.input";
|
||||
|
||||
export class WorkflowFormActivationDto {
|
||||
@ApiProperty({
|
||||
description: "Whether the workflow is active for all the routing forms",
|
||||
example: false,
|
||||
type: Boolean,
|
||||
})
|
||||
@IsBoolean()
|
||||
isActiveOnAllRoutingForms = false;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "List of routing form IDs the workflow applies to",
|
||||
example: ["abd1-123edf-a213d-123dfwf"],
|
||||
type: [Number],
|
||||
})
|
||||
@ValidateIf((o) => !o.isActiveOnAllEventTypes)
|
||||
@IsOptional()
|
||||
@IsString({ each: true })
|
||||
activeOnRoutingFormIds: string[] = [];
|
||||
}
|
||||
|
||||
@ApiExtraModels(
|
||||
OnFormSubmittedTriggerDto,
|
||||
OnFormSubmittedNoEventTriggerDto,
|
||||
WorkflowEmailAddressStepDto,
|
||||
WorkflowEmailAttendeeStepDto,
|
||||
RoutingFormWorkflowTriggerDto,
|
||||
WorkflowFormActivationDto
|
||||
)
|
||||
export class CreateFormWorkflowDto {
|
||||
@ApiProperty({ description: "Name of the workflow", example: "Platform Test Workflow" })
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Activation settings for the workflow",
|
||||
type: WorkflowFormActivationDto,
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => WorkflowFormActivationDto)
|
||||
activation!: WorkflowFormActivationDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: `Trigger configuration for the routing-form workflow, allowed triggers are ${FORM_WORKFLOW_TRIGGER_TYPES.toString()}`,
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(OnFormSubmittedTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnFormSubmittedNoEventTriggerDto) },
|
||||
],
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => RoutingFormWorkflowTriggerDto, {
|
||||
keepDiscriminatorProperty: true,
|
||||
discriminator: {
|
||||
property: "type",
|
||||
subTypes: [
|
||||
{ value: OnFormSubmittedTriggerDto, name: FORM_SUBMITTED },
|
||||
{ value: OnFormSubmittedNoEventTriggerDto, name: FORM_SUBMITTED_NO_EVENT },
|
||||
],
|
||||
},
|
||||
})
|
||||
trigger!: OnFormSubmittedTriggerDto | OnFormSubmittedNoEventTriggerDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: `Steps to execute as part of the routing-form workflow, allowed steps are ${FORM_ALLOWED_STEP_ACTIONS.toString()}`,
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(WorkflowEmailAddressStepDto) },
|
||||
{ $ref: getSchemaPath(WorkflowEmailAttendeeStepDto) },
|
||||
],
|
||||
type: "array",
|
||||
})
|
||||
@ValidateNested({ each: true })
|
||||
@ArrayMinSize(1, {
|
||||
message: `Your workflow must contain at least one allowed step. allowed steps are ${FORM_ALLOWED_STEP_ACTIONS.toString()}`,
|
||||
})
|
||||
@Type(() => BaseFormWorkflowStepDto, {
|
||||
keepDiscriminatorProperty: true,
|
||||
discriminator: {
|
||||
property: "action",
|
||||
subTypes: [
|
||||
{ value: WorkflowEmailAddressStepDto, name: EMAIL_ADDRESS },
|
||||
{ value: WorkflowEmailAttendeeStepDto, name: EMAIL_ATTENDEE },
|
||||
],
|
||||
},
|
||||
})
|
||||
steps!: (WorkflowEmailAddressStepDto | WorkflowEmailAttendeeStepDto)[];
|
||||
}
|
||||
+25
-93
@@ -1,16 +1,9 @@
|
||||
import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsNumber, IsString, IsOptional, ValidateNested, ArrayMinSize } from "class-validator";
|
||||
import { IsString, IsOptional, ValidateNested, ArrayMinSize } from "class-validator";
|
||||
|
||||
import { WorkflowActivationDto } from "./create-workflow.input";
|
||||
import { WorkflowActivationDto } from "./create-event-type-workflow.input";
|
||||
import {
|
||||
WorkflowEmailAttendeeStepDto,
|
||||
WorkflowEmailAddressStepDto,
|
||||
WorkflowEmailHostStepDto,
|
||||
WorkflowPhoneWhatsAppNumberStepDto,
|
||||
WorkflowPhoneAttendeeStepDto,
|
||||
WorkflowPhoneNumberStepDto,
|
||||
WorkflowPhoneWhatsAppAttendeeStepDto,
|
||||
BaseWorkflowStepDto,
|
||||
EMAIL_ADDRESS,
|
||||
EMAIL_ATTENDEE,
|
||||
@@ -19,9 +12,17 @@ import {
|
||||
WHATSAPP_NUMBER,
|
||||
SMS_NUMBER,
|
||||
SMS_ATTENDEE,
|
||||
UpdateEmailAddressWorkflowStepDto,
|
||||
UpdateEmailAttendeeWorkflowStepDto,
|
||||
UpdateEmailHostWorkflowStepDto,
|
||||
UpdatePhoneAttendeeWorkflowStepDto,
|
||||
UpdatePhoneNumberWorkflowStepDto,
|
||||
UpdatePhoneWhatsAppNumberWorkflowStepDto,
|
||||
UpdateWhatsAppAttendeePhoneWorkflowStepDto,
|
||||
STEP_ACTIONS,
|
||||
} from "./workflow-step.input";
|
||||
import {
|
||||
BaseWorkflowTriggerDto,
|
||||
EventTypeWorkflowTriggerDto,
|
||||
OnBeforeEventTriggerDto,
|
||||
BEFORE_EVENT,
|
||||
OnAfterEventTriggerDto,
|
||||
@@ -46,83 +47,9 @@ import {
|
||||
BOOKING_PAYMENT_INITIATED,
|
||||
BOOKING_PAID,
|
||||
BOOKING_NO_SHOW_UPDATED,
|
||||
EVENT_TYPE_WORKFLOW_TRIGGER_TYPES,
|
||||
} from "./workflow-trigger.input";
|
||||
|
||||
export type UpdateWorkflowStepDto =
|
||||
| UpdateEmailAttendeeWorkflowStepDto
|
||||
| UpdateEmailAddressWorkflowStepDto
|
||||
| UpdateEmailHostWorkflowStepDto
|
||||
| UpdateWhatsAppAttendeePhoneWorkflowStepDto
|
||||
| UpdatePhoneWhatsAppNumberWorkflowStepDto
|
||||
| UpdatePhoneAttendeeWorkflowStepDto
|
||||
| UpdatePhoneNumberWorkflowStepDto;
|
||||
export class UpdateEmailAttendeeWorkflowStepDto extends WorkflowEmailAttendeeStepDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
|
||||
example: 67244,
|
||||
})
|
||||
@IsNumber()
|
||||
id?: number;
|
||||
}
|
||||
|
||||
export class UpdateEmailAddressWorkflowStepDto extends WorkflowEmailAddressStepDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
|
||||
example: 67244,
|
||||
})
|
||||
@IsNumber()
|
||||
id?: number;
|
||||
}
|
||||
|
||||
export class UpdateEmailHostWorkflowStepDto extends WorkflowEmailHostStepDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
|
||||
example: 67244,
|
||||
})
|
||||
@IsNumber()
|
||||
id?: number;
|
||||
}
|
||||
|
||||
export class UpdatePhoneWhatsAppNumberWorkflowStepDto extends WorkflowPhoneWhatsAppNumberStepDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
|
||||
example: 67244,
|
||||
})
|
||||
@IsNumber()
|
||||
id?: number;
|
||||
}
|
||||
export class UpdatePhoneAttendeeWorkflowStepDto extends WorkflowPhoneAttendeeStepDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
|
||||
example: 67244,
|
||||
})
|
||||
@IsNumber()
|
||||
id?: number;
|
||||
}
|
||||
export class UpdatePhoneNumberWorkflowStepDto extends WorkflowPhoneNumberStepDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
|
||||
example: 67244,
|
||||
})
|
||||
@IsNumber()
|
||||
id?: number;
|
||||
}
|
||||
export class UpdateWhatsAppAttendeePhoneWorkflowStepDto extends WorkflowPhoneWhatsAppAttendeeStepDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
|
||||
example: 67244,
|
||||
})
|
||||
@IsNumber()
|
||||
id?: number;
|
||||
}
|
||||
|
||||
@ApiExtraModels(
|
||||
OnBeforeEventTriggerDto,
|
||||
OnAfterEventTriggerDto,
|
||||
@@ -143,16 +70,17 @@ export class UpdateWhatsAppAttendeePhoneWorkflowStepDto extends WorkflowPhoneWha
|
||||
UpdatePhoneWhatsAppNumberWorkflowStepDto,
|
||||
UpdateWhatsAppAttendeePhoneWorkflowStepDto,
|
||||
UpdatePhoneNumberWorkflowStepDto,
|
||||
BaseWorkflowTriggerDto
|
||||
EventTypeWorkflowTriggerDto,
|
||||
WorkflowActivationDto
|
||||
)
|
||||
export class UpdateWorkflowDto {
|
||||
export class UpdateEventTypeWorkflowDto {
|
||||
@ApiPropertyOptional({ description: "Name of the workflow", example: "Platform Test Workflow" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Activation settings for the workflow, the action that will trigger the workflow.",
|
||||
@ApiProperty({
|
||||
description: "Activation settings for the workflow",
|
||||
type: WorkflowActivationDto,
|
||||
})
|
||||
@ValidateNested()
|
||||
@@ -161,7 +89,7 @@ export class UpdateWorkflowDto {
|
||||
activation?: WorkflowActivationDto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Trigger configuration for the workflow",
|
||||
description: `Trigger configuration for the event-type workflow, allowed triggers are ${EVENT_TYPE_WORKFLOW_TRIGGER_TYPES.toString()}`,
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(OnBeforeEventTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnAfterEventTriggerDto) },
|
||||
@@ -179,7 +107,8 @@ export class UpdateWorkflowDto {
|
||||
})
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => BaseWorkflowTriggerDto, {
|
||||
@Type(() => EventTypeWorkflowTriggerDto, {
|
||||
keepDiscriminatorProperty: true,
|
||||
discriminator: {
|
||||
property: "type",
|
||||
subTypes: [
|
||||
@@ -213,7 +142,7 @@ export class UpdateWorkflowDto {
|
||||
| OnAfterCalVideoHostsNoShowTriggerDto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Steps to execute as part of the workflow",
|
||||
description: `Steps to execute as part of the event-type workflow, allowed steps are ${STEP_ACTIONS.toString()}`,
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(UpdateEmailAddressWorkflowStepDto) },
|
||||
{ $ref: getSchemaPath(UpdateEmailAttendeeWorkflowStepDto) },
|
||||
@@ -226,9 +155,12 @@ export class UpdateWorkflowDto {
|
||||
type: "array",
|
||||
})
|
||||
@ValidateNested({ each: true })
|
||||
@ArrayMinSize(1, { message: "Your workflow must contain at least one step." })
|
||||
@ArrayMinSize(1, {
|
||||
message: `Your workflow must contain at least one allowed step. allowed steps are ${STEP_ACTIONS.toString()}`,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => BaseWorkflowStepDto, {
|
||||
keepDiscriminatorProperty: true,
|
||||
discriminator: {
|
||||
property: "action",
|
||||
subTypes: [
|
||||
@@ -0,0 +1,97 @@
|
||||
import { WorkflowFormActivationDto } from "@/modules/workflows/inputs/create-form-workflow";
|
||||
import { ApiExtraModels, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsString, IsOptional, ValidateNested, ArrayMinSize } from "class-validator";
|
||||
|
||||
import {
|
||||
BaseFormWorkflowStepDto,
|
||||
EMAIL_ADDRESS,
|
||||
EMAIL_ATTENDEE,
|
||||
FORM_ALLOWED_STEP_ACTIONS,
|
||||
UpdateEmailAddressWorkflowStepDto,
|
||||
UpdateEmailAttendeeWorkflowStepDto,
|
||||
UpdateEmailHostWorkflowStepDto,
|
||||
UpdatePhoneAttendeeWorkflowStepDto,
|
||||
UpdatePhoneNumberWorkflowStepDto,
|
||||
UpdatePhoneWhatsAppNumberWorkflowStepDto,
|
||||
UpdateWhatsAppAttendeePhoneWorkflowStepDto,
|
||||
} from "./workflow-step.input";
|
||||
import {
|
||||
OnFormSubmittedTriggerDto,
|
||||
OnFormSubmittedNoEventTriggerDto,
|
||||
FORM_SUBMITTED,
|
||||
FORM_SUBMITTED_NO_EVENT,
|
||||
FORM_WORKFLOW_TRIGGER_TYPES,
|
||||
RoutingFormWorkflowTriggerDto,
|
||||
} from "./workflow-trigger.input";
|
||||
|
||||
@ApiExtraModels(
|
||||
OnFormSubmittedTriggerDto,
|
||||
OnFormSubmittedNoEventTriggerDto,
|
||||
UpdateEmailAddressWorkflowStepDto,
|
||||
UpdateEmailAttendeeWorkflowStepDto,
|
||||
UpdateEmailHostWorkflowStepDto,
|
||||
UpdatePhoneAttendeeWorkflowStepDto,
|
||||
UpdatePhoneWhatsAppNumberWorkflowStepDto,
|
||||
UpdateWhatsAppAttendeePhoneWorkflowStepDto,
|
||||
UpdatePhoneNumberWorkflowStepDto,
|
||||
RoutingFormWorkflowTriggerDto,
|
||||
WorkflowFormActivationDto
|
||||
)
|
||||
export class UpdateFormWorkflowDto {
|
||||
@ApiPropertyOptional({ description: "Name of the workflow", example: "Rounting-form Test Workflow" })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => WorkflowFormActivationDto)
|
||||
@IsOptional()
|
||||
activation?: WorkflowFormActivationDto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `Trigger configuration for the routing-form workflow, allowed triggers are ${FORM_WORKFLOW_TRIGGER_TYPES}`,
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(OnFormSubmittedTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnFormSubmittedNoEventTriggerDto) },
|
||||
],
|
||||
})
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => RoutingFormWorkflowTriggerDto, {
|
||||
keepDiscriminatorProperty: true,
|
||||
discriminator: {
|
||||
property: "type",
|
||||
subTypes: [
|
||||
{ value: OnFormSubmittedTriggerDto, name: FORM_SUBMITTED },
|
||||
{ value: OnFormSubmittedNoEventTriggerDto, name: FORM_SUBMITTED_NO_EVENT },
|
||||
],
|
||||
},
|
||||
})
|
||||
trigger?: OnFormSubmittedTriggerDto | OnFormSubmittedNoEventTriggerDto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `Steps to execute as part of the routing-form workflow, allowed steps are ${FORM_ALLOWED_STEP_ACTIONS.toString()}`,
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(UpdateEmailAddressWorkflowStepDto) },
|
||||
{ $ref: getSchemaPath(UpdateEmailAttendeeWorkflowStepDto) },
|
||||
],
|
||||
type: "array",
|
||||
})
|
||||
@ValidateNested({ each: true })
|
||||
@ArrayMinSize(1, {
|
||||
message: `Your workflow must contain at least one allowed step. allowed steps are ${FORM_ALLOWED_STEP_ACTIONS.toString()}`,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => BaseFormWorkflowStepDto, {
|
||||
keepDiscriminatorProperty: true,
|
||||
discriminator: {
|
||||
property: "action",
|
||||
subTypes: [
|
||||
{ value: UpdateEmailAddressWorkflowStepDto, name: EMAIL_ADDRESS },
|
||||
{ value: UpdateEmailAttendeeWorkflowStepDto, name: EMAIL_ATTENDEE },
|
||||
],
|
||||
},
|
||||
})
|
||||
steps?: (UpdateEmailAddressWorkflowStepDto | UpdateEmailAttendeeWorkflowStepDto)[];
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsNumber, IsBoolean, IsString, ValidateNested, IsIn } from "class-validator";
|
||||
import { IsNumber, IsBoolean, IsString, ValidateNested, IsIn, IsOptional } from "class-validator";
|
||||
|
||||
import { WorkflowActions, WorkflowTemplates } from "@calcom/platform-libraries";
|
||||
|
||||
@@ -24,6 +24,8 @@ export const STEP_ACTIONS = [
|
||||
CAL_AI_PHONE_CALL,
|
||||
] as const;
|
||||
|
||||
export const FORM_ALLOWED_STEP_ACTIONS = [EMAIL_ATTENDEE, EMAIL_ADDRESS] as const;
|
||||
|
||||
export const STEP_ACTIONS_TO_ENUM = {
|
||||
[EMAIL_HOST]: WorkflowActions.EMAIL_HOST,
|
||||
[EMAIL_ATTENDEE]: WorkflowActions.EMAIL_ATTENDEE,
|
||||
@@ -47,7 +49,7 @@ export const ENUM_TO_STEP_ACTIONS = {
|
||||
} as const;
|
||||
|
||||
export type StepAction = (typeof STEP_ACTIONS)[number];
|
||||
export type StepActionsType = (typeof STEP_ACTIONS)[number];
|
||||
export type FormAllowedStepAction = (typeof FORM_ALLOWED_STEP_ACTIONS)[number];
|
||||
|
||||
export const REMINDER = "reminder";
|
||||
export const CUSTOM = "custom";
|
||||
@@ -134,6 +136,13 @@ export class BaseWorkflowStepDto {
|
||||
sender!: string;
|
||||
}
|
||||
|
||||
export class BaseFormWorkflowStepDto extends BaseWorkflowStepDto {
|
||||
@ApiProperty({ description: "Action to perform", example: EMAIL_HOST, enum: STEP_ACTIONS })
|
||||
@IsString()
|
||||
@IsIn(FORM_ALLOWED_STEP_ACTIONS)
|
||||
action!: FormAllowedStepAction;
|
||||
}
|
||||
|
||||
export class WorkflowEmailHostStepDto extends BaseWorkflowStepDto {
|
||||
@ApiProperty({
|
||||
description: "Action to perform, send an email to the host of the event",
|
||||
@@ -260,6 +269,15 @@ export class WorkflowPhoneAttendeeStepDto extends BaseWorkflowStepDto {
|
||||
@ValidateNested()
|
||||
@Type(() => TextWorkflowMessageDto)
|
||||
message!: TextWorkflowMessageDto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "whether or not the attendees are required to provide their phone numbers when booking",
|
||||
example: true,
|
||||
default: false,
|
||||
})
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
phoneRequired: boolean = false;
|
||||
}
|
||||
|
||||
export class WorkflowPhoneNumberStepDto extends BaseWorkflowStepDto {
|
||||
@@ -302,4 +320,88 @@ export class WorkflowPhoneWhatsAppAttendeeStepDto extends BaseWorkflowStepDto {
|
||||
@ValidateNested()
|
||||
@Type(() => TextWorkflowMessageDto)
|
||||
message!: TextWorkflowMessageDto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "whether or not the attendees are required to provide their phone numbers when booking",
|
||||
example: true,
|
||||
default: false,
|
||||
})
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
phoneRequired: boolean = false;
|
||||
}
|
||||
|
||||
export type UpdateWorkflowStepDto =
|
||||
| UpdateEmailAttendeeWorkflowStepDto
|
||||
| UpdateEmailAddressWorkflowStepDto
|
||||
| UpdateEmailHostWorkflowStepDto
|
||||
| UpdateWhatsAppAttendeePhoneWorkflowStepDto
|
||||
| UpdatePhoneWhatsAppNumberWorkflowStepDto
|
||||
| UpdatePhoneAttendeeWorkflowStepDto
|
||||
| UpdatePhoneNumberWorkflowStepDto;
|
||||
export class UpdateEmailAttendeeWorkflowStepDto extends WorkflowEmailAttendeeStepDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
|
||||
example: 67244,
|
||||
})
|
||||
@IsNumber()
|
||||
id?: number;
|
||||
}
|
||||
|
||||
export class UpdateEmailAddressWorkflowStepDto extends WorkflowEmailAddressStepDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
|
||||
example: 67244,
|
||||
})
|
||||
@IsNumber()
|
||||
id?: number;
|
||||
}
|
||||
|
||||
export class UpdateEmailHostWorkflowStepDto extends WorkflowEmailHostStepDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
|
||||
example: 67244,
|
||||
})
|
||||
@IsNumber()
|
||||
id?: number;
|
||||
}
|
||||
|
||||
export class UpdatePhoneWhatsAppNumberWorkflowStepDto extends WorkflowPhoneWhatsAppNumberStepDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
|
||||
example: 67244,
|
||||
})
|
||||
@IsNumber()
|
||||
id?: number;
|
||||
}
|
||||
export class UpdatePhoneAttendeeWorkflowStepDto extends WorkflowPhoneAttendeeStepDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
|
||||
example: 67244,
|
||||
})
|
||||
@IsNumber()
|
||||
id?: number;
|
||||
}
|
||||
export class UpdatePhoneNumberWorkflowStepDto extends WorkflowPhoneNumberStepDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
|
||||
example: 67244,
|
||||
})
|
||||
@IsNumber()
|
||||
id?: number;
|
||||
}
|
||||
export class UpdateWhatsAppAttendeePhoneWorkflowStepDto extends WorkflowPhoneWhatsAppAttendeeStepDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique identifier of the step you want to update, if adding a new step do not provide this id",
|
||||
example: 67244,
|
||||
})
|
||||
@IsNumber()
|
||||
id?: number;
|
||||
}
|
||||
|
||||
@@ -11,11 +11,31 @@ export const AFTER_EVENT = "afterEvent";
|
||||
export const RESCHEDULE_EVENT = "rescheduleEvent";
|
||||
export const AFTER_HOSTS_CAL_VIDEO_NO_SHOW = "afterHostsCalVideoNoShow";
|
||||
export const AFTER_GUESTS_CAL_VIDEO_NO_SHOW = "afterGuestsCalVideoNoShow";
|
||||
export const FORM_SUBMITTED = "formSubmitted";
|
||||
export const FORM_SUBMITTED_NO_EVENT = "formSubmittedNoEvent";
|
||||
export const BOOKING_REJECTED = "bookingRejected";
|
||||
export const BOOKING_REQUESTED = "bookingRequested";
|
||||
export const BOOKING_PAYMENT_INITIATED = "bookingPaymentInitiated";
|
||||
export const BOOKING_PAID = "bookingPaid";
|
||||
export const BOOKING_NO_SHOW_UPDATED = "bookingNoShowUpdated";
|
||||
|
||||
export const FORM_WORKFLOW_TRIGGER_TYPES = [FORM_SUBMITTED, FORM_SUBMITTED_NO_EVENT] as const;
|
||||
|
||||
export const EVENT_TYPE_WORKFLOW_TRIGGER_TYPES = [
|
||||
BEFORE_EVENT,
|
||||
EVENT_CANCELLED,
|
||||
NEW_EVENT,
|
||||
AFTER_EVENT,
|
||||
RESCHEDULE_EVENT,
|
||||
AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
|
||||
BOOKING_REJECTED,
|
||||
BOOKING_REQUESTED,
|
||||
BOOKING_PAYMENT_INITIATED,
|
||||
BOOKING_PAID,
|
||||
BOOKING_NO_SHOW_UPDATED,
|
||||
] as const;
|
||||
|
||||
export const WORKFLOW_TRIGGER_TYPES = [
|
||||
BEFORE_EVENT,
|
||||
EVENT_CANCELLED,
|
||||
@@ -24,6 +44,8 @@ export const WORKFLOW_TRIGGER_TYPES = [
|
||||
RESCHEDULE_EVENT,
|
||||
AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
|
||||
FORM_SUBMITTED,
|
||||
FORM_SUBMITTED_NO_EVENT,
|
||||
BOOKING_REJECTED,
|
||||
BOOKING_REQUESTED,
|
||||
BOOKING_PAYMENT_INITIATED,
|
||||
@@ -39,6 +61,8 @@ export const WORKFLOW_TRIGGER_TO_ENUM = {
|
||||
[RESCHEDULE_EVENT]: WorkflowTriggerEvents.RESCHEDULE_EVENT,
|
||||
[AFTER_HOSTS_CAL_VIDEO_NO_SHOW]: WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
[AFTER_GUESTS_CAL_VIDEO_NO_SHOW]: WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
|
||||
[FORM_SUBMITTED]: WorkflowTriggerEvents.FORM_SUBMITTED,
|
||||
[FORM_SUBMITTED_NO_EVENT]: WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT,
|
||||
[BOOKING_REJECTED]: WorkflowTriggerEvents.BOOKING_REJECTED,
|
||||
[BOOKING_REQUESTED]: WorkflowTriggerEvents.BOOKING_REQUESTED,
|
||||
[BOOKING_PAYMENT_INITIATED]: WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED,
|
||||
@@ -46,6 +70,19 @@ export const WORKFLOW_TRIGGER_TO_ENUM = {
|
||||
[BOOKING_PAID]: WorkflowTriggerEvents.BOOKING_PAID,
|
||||
} as const;
|
||||
|
||||
export const ENUM_ROUTING_FORM_WORFLOW_TRIGGERS = [
|
||||
WORKFLOW_TRIGGER_TO_ENUM[FORM_SUBMITTED_NO_EVENT],
|
||||
WORKFLOW_TRIGGER_TO_ENUM[FORM_SUBMITTED],
|
||||
];
|
||||
|
||||
export const ENUM_OFFSET_WORFLOW_TRIGGERS = [
|
||||
WORKFLOW_TRIGGER_TO_ENUM[FORM_SUBMITTED_NO_EVENT],
|
||||
WORKFLOW_TRIGGER_TO_ENUM[BEFORE_EVENT],
|
||||
WORKFLOW_TRIGGER_TO_ENUM[AFTER_EVENT],
|
||||
WORKFLOW_TRIGGER_TO_ENUM[AFTER_GUESTS_CAL_VIDEO_NO_SHOW],
|
||||
WORKFLOW_TRIGGER_TO_ENUM[AFTER_HOSTS_CAL_VIDEO_NO_SHOW],
|
||||
];
|
||||
|
||||
export const ENUM_TO_WORKFLOW_TRIGGER = {
|
||||
[WorkflowTriggerEvents.BEFORE_EVENT]: BEFORE_EVENT,
|
||||
[WorkflowTriggerEvents.EVENT_CANCELLED]: EVENT_CANCELLED,
|
||||
@@ -54,6 +91,8 @@ export const ENUM_TO_WORKFLOW_TRIGGER = {
|
||||
[WorkflowTriggerEvents.RESCHEDULE_EVENT]: RESCHEDULE_EVENT,
|
||||
[WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW]: AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
[WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW]: AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
|
||||
[WorkflowTriggerEvents.FORM_SUBMITTED]: FORM_SUBMITTED,
|
||||
[WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT]: FORM_SUBMITTED_NO_EVENT,
|
||||
[WorkflowTriggerEvents.BOOKING_REJECTED]: BOOKING_REJECTED,
|
||||
[WorkflowTriggerEvents.BOOKING_REQUESTED]: BOOKING_REQUESTED,
|
||||
[WorkflowTriggerEvents.BOOKING_PAYMENT_INITIATED]: BOOKING_PAYMENT_INITIATED,
|
||||
@@ -61,6 +100,10 @@ export const ENUM_TO_WORKFLOW_TRIGGER = {
|
||||
[WorkflowTriggerEvents.BOOKING_NO_SHOW_UPDATED]: BOOKING_NO_SHOW_UPDATED,
|
||||
} as const;
|
||||
|
||||
export const ENUM_TO_ROUNTING_FORM_WORKFLOW_TRIGGER = {
|
||||
[WorkflowTriggerEvents.FORM_SUBMITTED]: FORM_SUBMITTED,
|
||||
} as const;
|
||||
|
||||
export const HOUR = "hour";
|
||||
export const MINUTE = "minute";
|
||||
export const DAY = "day";
|
||||
@@ -82,6 +125,8 @@ export const ENUM_TO_TIME_UNIT = {
|
||||
} as const;
|
||||
|
||||
export type WorkflowTriggerType = (typeof WORKFLOW_TRIGGER_TYPES)[number];
|
||||
export type WorkflowEventTypeTriggerType = (typeof EVENT_TYPE_WORKFLOW_TRIGGER_TYPES)[number];
|
||||
export type WorkflowFormTriggerType = (typeof FORM_WORKFLOW_TRIGGER_TYPES)[number];
|
||||
|
||||
export class WorkflowTriggerOffsetDto {
|
||||
@ApiProperty({ description: "Time value for offset before/after event trigger", example: 24, type: Number })
|
||||
@@ -94,13 +139,24 @@ export class WorkflowTriggerOffsetDto {
|
||||
unit!: TimeUnitType;
|
||||
}
|
||||
|
||||
export class BaseWorkflowTriggerDto {
|
||||
export class EventTypeWorkflowTriggerDto {
|
||||
@ApiProperty({
|
||||
description: "Trigger type for the workflow",
|
||||
description: "Trigger type for the event-type workflow",
|
||||
example: "beforeEvent",
|
||||
})
|
||||
@IsString()
|
||||
@IsIn([WORKFLOW_TRIGGER_TYPES])
|
||||
type!: WorkflowTriggerType;
|
||||
@IsIn(EVENT_TYPE_WORKFLOW_TRIGGER_TYPES)
|
||||
type!: WorkflowEventTypeTriggerType;
|
||||
}
|
||||
|
||||
export class RoutingFormWorkflowTriggerDto {
|
||||
@ApiProperty({
|
||||
description: "Trigger type for the routing-form workflow",
|
||||
example: "formSubmitted",
|
||||
})
|
||||
@IsString()
|
||||
@IsIn(FORM_WORKFLOW_TRIGGER_TYPES)
|
||||
type!: WorkflowFormTriggerType;
|
||||
}
|
||||
|
||||
export class OnCreationTriggerDto {
|
||||
@@ -166,7 +222,8 @@ export class OnNoShowUpdateTriggerDto {
|
||||
|
||||
export class TriggerOffsetDTO {
|
||||
@ApiProperty({
|
||||
description: "Offset before/after the trigger time; required for BEFORE_EVENT and AFTER_EVENT only",
|
||||
description:
|
||||
"Offset before/after the trigger time; required for BEFORE_EVENT, AFTER_EVENT, and FORM_SUBMITTED_NO_EVENT",
|
||||
type: WorkflowTriggerOffsetDto,
|
||||
})
|
||||
@ValidateNested()
|
||||
@@ -213,3 +270,31 @@ export class OnAfterCalVideoHostsNoShowTriggerDto extends TriggerOffsetDTO {
|
||||
@IsIn([AFTER_HOSTS_CAL_VIDEO_NO_SHOW])
|
||||
type: typeof AFTER_HOSTS_CAL_VIDEO_NO_SHOW = AFTER_HOSTS_CAL_VIDEO_NO_SHOW;
|
||||
}
|
||||
export class OnFormSubmittedTriggerDto {
|
||||
@ApiProperty({
|
||||
description: "Trigger type for the workflow",
|
||||
example: FORM_SUBMITTED,
|
||||
})
|
||||
@IsString()
|
||||
@IsIn([FORM_SUBMITTED])
|
||||
type: typeof FORM_SUBMITTED = FORM_SUBMITTED;
|
||||
}
|
||||
|
||||
export class OnFormSubmittedNoEventTriggerDto extends TriggerOffsetDTO {
|
||||
@ApiProperty({
|
||||
description: "Trigger type for the workflow",
|
||||
example: FORM_SUBMITTED_NO_EVENT,
|
||||
})
|
||||
@IsString()
|
||||
@IsIn([FORM_SUBMITTED_NO_EVENT])
|
||||
type: typeof FORM_SUBMITTED_NO_EVENT = FORM_SUBMITTED_NO_EVENT;
|
||||
}
|
||||
|
||||
export const OffsetTriggerDTOInstances = [
|
||||
OnFormSubmittedNoEventTriggerDto,
|
||||
OnBeforeEventTriggerDto,
|
||||
OnAfterEventTriggerDto,
|
||||
OnAfterCalVideoGuestsNoShowTriggerDto,
|
||||
OnAfterEventTriggerDto,
|
||||
];
|
||||
export type OffsetTriggerDTOInstancesType = InstanceType<(typeof OffsetTriggerDTOInstances)[number]>;
|
||||
|
||||
+17
-125
@@ -1,28 +1,15 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsArray, IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
import {
|
||||
EMAIL_HOST,
|
||||
HOST,
|
||||
RECIPIENT_TYPES,
|
||||
RecipientType,
|
||||
REMINDER,
|
||||
STEP_ACTIONS,
|
||||
StepAction,
|
||||
TEMPLATES,
|
||||
TemplateType,
|
||||
} from "../inputs/workflow-step.input";
|
||||
import {
|
||||
BEFORE_EVENT,
|
||||
HOUR,
|
||||
TIME_UNITS,
|
||||
TimeUnitType,
|
||||
WORKFLOW_TRIGGER_TYPES,
|
||||
WorkflowTriggerType,
|
||||
} from "../inputs/workflow-trigger.input";
|
||||
} from "@/modules/workflows/inputs/workflow-step.input";
|
||||
import { HOUR, TIME_UNITS, TimeUnitType } from "@/modules/workflows/inputs/workflow-trigger.input";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsBoolean, IsOptional, ValidateNested } from "class-validator";
|
||||
|
||||
export class WorkflowMessageOutputDto {
|
||||
@ApiProperty({
|
||||
@@ -47,7 +34,7 @@ export class WorkflowMessageOutputDto {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export class WorkflowStepOutputDto {
|
||||
export class BaseWorkflowStepOutputDto {
|
||||
@ApiProperty({ description: "Unique identifier of the step", example: 67244 })
|
||||
@Expose()
|
||||
id!: number;
|
||||
@@ -56,10 +43,6 @@ export class WorkflowStepOutputDto {
|
||||
@Expose()
|
||||
stepNumber!: number;
|
||||
|
||||
@ApiProperty({ description: "Action to perform", example: EMAIL_HOST, enum: STEP_ACTIONS })
|
||||
@Expose()
|
||||
action!: StepAction;
|
||||
|
||||
@ApiProperty({ description: "Intended recipient type", example: HOST, enum: RECIPIENT_TYPES })
|
||||
@Expose()
|
||||
recipient!: RecipientType;
|
||||
@@ -74,6 +57,16 @@ export class WorkflowStepOutputDto {
|
||||
@Expose()
|
||||
phone?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "whether or not the attendees are required to provide their phone numbers when booking",
|
||||
example: true,
|
||||
default: false,
|
||||
})
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
@IsOptional()
|
||||
phoneRequired?: boolean;
|
||||
|
||||
@ApiProperty({ description: "Template type used", example: REMINDER, enum: TEMPLATES })
|
||||
@Expose()
|
||||
template!: TemplateType;
|
||||
@@ -110,45 +103,7 @@ export class WorkflowTriggerOffsetOutputDto {
|
||||
unit!: TimeUnitType;
|
||||
}
|
||||
|
||||
export class WorkflowTriggerOutputDto {
|
||||
@ApiProperty({
|
||||
description: "Trigger type for the workflow",
|
||||
example: BEFORE_EVENT,
|
||||
enum: WORKFLOW_TRIGGER_TYPES,
|
||||
})
|
||||
@Expose()
|
||||
type!: WorkflowTriggerType;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Offset details (present for BEFORE_EVENT/AFTER_EVENT)",
|
||||
type: WorkflowTriggerOffsetOutputDto,
|
||||
})
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => WorkflowTriggerOffsetOutputDto)
|
||||
offset?: WorkflowTriggerOffsetOutputDto;
|
||||
}
|
||||
|
||||
export class WorkflowActivationOutputDto {
|
||||
@ApiProperty({
|
||||
description: "Whether the workflow is active for all event types associated with the team/user",
|
||||
example: false,
|
||||
})
|
||||
@Expose()
|
||||
isActiveOnAllEventTypes?: boolean = false;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "List of Event Type IDs the workflow is specifically active on (if not active on all)",
|
||||
example: [698191, 698192],
|
||||
})
|
||||
@Expose()
|
||||
@IsArray()
|
||||
activeOnEventTypeIds?: number[];
|
||||
}
|
||||
|
||||
// --- Main Workflow Output DTO ---
|
||||
|
||||
export class WorkflowOutput {
|
||||
export class BaseWorkflowOutput {
|
||||
@ApiProperty({ description: "Unique identifier of the workflow", example: 101 })
|
||||
@Expose()
|
||||
id!: number;
|
||||
@@ -168,25 +123,6 @@ export class WorkflowOutput {
|
||||
@Expose()
|
||||
teamId?: number;
|
||||
|
||||
@ApiProperty({ description: "Activation settings (scope)", type: WorkflowActivationOutputDto })
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => WorkflowActivationOutputDto)
|
||||
activation!: WorkflowActivationOutputDto;
|
||||
|
||||
@ApiProperty({ description: "Trigger configuration", type: WorkflowTriggerOutputDto })
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => WorkflowTriggerOutputDto)
|
||||
trigger!: WorkflowTriggerOutputDto;
|
||||
|
||||
@ApiProperty({ description: "Steps comprising the workflow", type: [WorkflowStepOutputDto] })
|
||||
@Expose()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WorkflowStepOutputDto)
|
||||
steps!: WorkflowStepOutputDto[];
|
||||
|
||||
@ApiPropertyOptional({ description: "Timestamp of creation", example: "2024-05-12T10:00:00.000Z" })
|
||||
@Expose()
|
||||
createdAt?: Date | string;
|
||||
@@ -195,47 +131,3 @@ export class WorkflowOutput {
|
||||
@Expose()
|
||||
updatedAt?: Date | string;
|
||||
}
|
||||
|
||||
// --- List Response Output DTO ---
|
||||
|
||||
export class GetWorkflowsOutput {
|
||||
@ApiProperty({
|
||||
description: "Indicates the status of the response",
|
||||
example: SUCCESS_STATUS,
|
||||
enum: [SUCCESS_STATUS, ERROR_STATUS],
|
||||
})
|
||||
@Expose()
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
description: "List of workflows",
|
||||
type: [WorkflowOutput],
|
||||
})
|
||||
@Expose()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WorkflowOutput)
|
||||
data!: WorkflowOutput[];
|
||||
}
|
||||
|
||||
export class GetWorkflowOutput {
|
||||
@ApiProperty({
|
||||
description: "Indicates the status of the response",
|
||||
example: SUCCESS_STATUS,
|
||||
enum: [SUCCESS_STATUS, ERROR_STATUS],
|
||||
})
|
||||
@Expose()
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
description: "workflow",
|
||||
type: [WorkflowOutput],
|
||||
})
|
||||
@Expose()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WorkflowOutput)
|
||||
data!: WorkflowOutput;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
BaseWorkflowStepOutputDto,
|
||||
WorkflowTriggerOffsetOutputDto,
|
||||
BaseWorkflowOutput,
|
||||
} from "@/modules/workflows/outputs/base-workflow.output";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsArray, IsEnum, IsIn, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
import { EMAIL_HOST, STEP_ACTIONS, StepAction } from "../inputs/workflow-step.input";
|
||||
import {
|
||||
BEFORE_EVENT,
|
||||
EVENT_TYPE_WORKFLOW_TRIGGER_TYPES,
|
||||
WorkflowEventTypeTriggerType,
|
||||
} from "../inputs/workflow-trigger.input";
|
||||
|
||||
export const WORKFLOW_TYPE_FORM = "routing-form";
|
||||
export const WORKFLOW_TYPE_EVENT_TYPE = "event-type";
|
||||
|
||||
export class EventTypeWorkflowStepOutputDto extends BaseWorkflowStepOutputDto {
|
||||
@ApiProperty({ description: "Action to perform", example: EMAIL_HOST, enum: STEP_ACTIONS })
|
||||
@Expose()
|
||||
action!: StepAction;
|
||||
}
|
||||
|
||||
export class EventTypeWorkflowTriggerOutputDto {
|
||||
@ApiProperty({
|
||||
description: "Trigger type for the workflow",
|
||||
example: BEFORE_EVENT,
|
||||
enum: EVENT_TYPE_WORKFLOW_TRIGGER_TYPES,
|
||||
})
|
||||
@Expose()
|
||||
type!: WorkflowEventTypeTriggerType;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Offset details (present for BEFORE_EVENT/AFTER_EVENT)",
|
||||
type: WorkflowTriggerOffsetOutputDto,
|
||||
})
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => WorkflowTriggerOffsetOutputDto)
|
||||
offset?: WorkflowTriggerOffsetOutputDto;
|
||||
}
|
||||
|
||||
export class EventTypeWorkflowActivationOutputDto {
|
||||
@ApiProperty({
|
||||
description: "Whether the workflow is active for all event types associated with the team/user",
|
||||
example: false,
|
||||
})
|
||||
@Expose()
|
||||
isActiveOnAllEventTypes?: boolean = false;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "List of Event Type IDs the workflow is specifically active on (if not active on all)",
|
||||
example: [698191, 698192],
|
||||
})
|
||||
@Expose()
|
||||
@IsArray()
|
||||
activeOnEventTypeIds?: number[];
|
||||
}
|
||||
|
||||
// --- Main Workflow Output DTO ---
|
||||
|
||||
export class EventTypeWorkflowOutput extends BaseWorkflowOutput {
|
||||
@ApiProperty({
|
||||
description: "type of the workflow",
|
||||
example: WORKFLOW_TYPE_EVENT_TYPE,
|
||||
default: WORKFLOW_TYPE_EVENT_TYPE,
|
||||
})
|
||||
@IsString()
|
||||
@IsIn([WORKFLOW_TYPE_EVENT_TYPE])
|
||||
type!: typeof WORKFLOW_TYPE_EVENT_TYPE;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Activation settings for the workflow",
|
||||
})
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => EventTypeWorkflowActivationOutputDto)
|
||||
activation!: EventTypeWorkflowActivationOutputDto;
|
||||
|
||||
@ApiProperty({ description: "Trigger configuration", type: EventTypeWorkflowTriggerOutputDto })
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => EventTypeWorkflowTriggerOutputDto)
|
||||
trigger!: EventTypeWorkflowTriggerOutputDto;
|
||||
|
||||
@ApiProperty({ description: "Steps comprising the workflow", type: [EventTypeWorkflowStepOutputDto] })
|
||||
@Expose()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EventTypeWorkflowStepOutputDto)
|
||||
steps!: EventTypeWorkflowStepOutputDto[];
|
||||
}
|
||||
|
||||
// --- List Response Output DTO ---
|
||||
|
||||
export class GetEventTypeWorkflowsOutput {
|
||||
@ApiProperty({
|
||||
description: "Indicates the status of the response",
|
||||
example: SUCCESS_STATUS,
|
||||
enum: [SUCCESS_STATUS, ERROR_STATUS],
|
||||
})
|
||||
@Expose()
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
description: "List of workflows",
|
||||
type: [EventTypeWorkflowOutput],
|
||||
})
|
||||
@Expose()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EventTypeWorkflowOutput)
|
||||
data!: EventTypeWorkflowOutput[];
|
||||
}
|
||||
|
||||
export class GetEventTypeWorkflowOutput {
|
||||
@ApiProperty({
|
||||
description: "Indicates the status of the response",
|
||||
example: SUCCESS_STATUS,
|
||||
enum: [SUCCESS_STATUS, ERROR_STATUS],
|
||||
})
|
||||
@Expose()
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
description: "workflow",
|
||||
type: [EventTypeWorkflowOutput],
|
||||
})
|
||||
@Expose()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EventTypeWorkflowOutput)
|
||||
data!: EventTypeWorkflowOutput;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
BaseWorkflowOutput,
|
||||
BaseWorkflowStepOutputDto,
|
||||
WorkflowTriggerOffsetOutputDto,
|
||||
} from "@/modules/workflows/outputs/base-workflow.output";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsArray, IsEnum, IsIn, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
import { EMAIL_HOST, FORM_ALLOWED_STEP_ACTIONS, FormAllowedStepAction } from "../inputs/workflow-step.input";
|
||||
import {
|
||||
FORM_SUBMITTED,
|
||||
FORM_WORKFLOW_TRIGGER_TYPES,
|
||||
WorkflowFormTriggerType,
|
||||
} from "../inputs/workflow-trigger.input";
|
||||
|
||||
export const WORKFLOW_TYPE_FORM = "routing-form";
|
||||
export const WORKFLOW_TYPE_EVENT_TYPE = "event-type";
|
||||
|
||||
export class RoutingFormWorkflowStepOutputDto extends BaseWorkflowStepOutputDto {
|
||||
@ApiProperty({ description: "Action to perform", example: EMAIL_HOST, enum: FORM_ALLOWED_STEP_ACTIONS })
|
||||
@Expose()
|
||||
action!: FormAllowedStepAction;
|
||||
}
|
||||
|
||||
export class RoutingFormWorkflowTriggerOutputDto {
|
||||
@ApiProperty({
|
||||
description: "Trigger type for the workflow",
|
||||
example: FORM_SUBMITTED,
|
||||
enum: FORM_WORKFLOW_TRIGGER_TYPES,
|
||||
})
|
||||
@Expose()
|
||||
type!: WorkflowFormTriggerType;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Offset details (present for BEFORE_EVENT/AFTER_EVENT/FORM_SUBMITTED_NO_EVENT)",
|
||||
type: WorkflowTriggerOffsetOutputDto,
|
||||
})
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => WorkflowTriggerOffsetOutputDto)
|
||||
offset?: WorkflowTriggerOffsetOutputDto;
|
||||
}
|
||||
|
||||
export class RoutingFormWorkflowActivationOutputDto {
|
||||
@ApiProperty({
|
||||
description: "Whether the workflow is active for all routing forms associated with the team/user",
|
||||
example: false,
|
||||
})
|
||||
@Expose()
|
||||
isActiveOnAllRoutingForms?: boolean = false;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "List of Event Type IDs the workflow is specifically active on (if not active on all)",
|
||||
example: ["5cacdec7-1234-6e1b-78d9-7bcda8a1b332"],
|
||||
})
|
||||
@Expose()
|
||||
@IsArray()
|
||||
activeOnRoutingFormIds?: string[];
|
||||
}
|
||||
|
||||
// --- Main Workflow Output DTO ---
|
||||
|
||||
export class RoutingFormWorkflowOutput extends BaseWorkflowOutput {
|
||||
@ApiProperty({
|
||||
description: "type of the workflow",
|
||||
example: WORKFLOW_TYPE_FORM,
|
||||
default: WORKFLOW_TYPE_FORM,
|
||||
})
|
||||
@IsString()
|
||||
@IsIn([WORKFLOW_TYPE_FORM])
|
||||
type!: typeof WORKFLOW_TYPE_FORM;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Activation settings for the workflow",
|
||||
})
|
||||
@Expose()
|
||||
@Type(() => RoutingFormWorkflowActivationOutputDto)
|
||||
@ValidateNested()
|
||||
activation!: RoutingFormWorkflowActivationOutputDto;
|
||||
|
||||
@ApiProperty({ description: "Trigger configuration", type: RoutingFormWorkflowTriggerOutputDto })
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => RoutingFormWorkflowTriggerOutputDto)
|
||||
trigger!: RoutingFormWorkflowTriggerOutputDto;
|
||||
|
||||
@ApiProperty({ description: "Steps comprising the workflow", type: [RoutingFormWorkflowStepOutputDto] })
|
||||
@Expose()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RoutingFormWorkflowStepOutputDto)
|
||||
steps!: RoutingFormWorkflowStepOutputDto[];
|
||||
}
|
||||
|
||||
export class GetRoutingFormWorkflowsOutput {
|
||||
@ApiProperty({
|
||||
description: "Indicates the status of the response",
|
||||
example: SUCCESS_STATUS,
|
||||
enum: [SUCCESS_STATUS, ERROR_STATUS],
|
||||
})
|
||||
@Expose()
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
description: "List of workflows",
|
||||
type: [RoutingFormWorkflowOutput],
|
||||
})
|
||||
@Expose()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RoutingFormWorkflowOutput)
|
||||
data!: RoutingFormWorkflowOutput[];
|
||||
}
|
||||
|
||||
export class GetRoutingFormWorkflowOutput {
|
||||
@ApiProperty({
|
||||
description: "Indicates the status of the response",
|
||||
example: SUCCESS_STATUS,
|
||||
enum: [SUCCESS_STATUS, ERROR_STATUS],
|
||||
})
|
||||
@Expose()
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
description: "workflow",
|
||||
type: [RoutingFormWorkflowOutput],
|
||||
})
|
||||
@Expose()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RoutingFormWorkflowOutput)
|
||||
data!: RoutingFormWorkflowOutput;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
|
||||
import { CreateEventTypeWorkflowDto } from "@/modules/workflows/inputs/create-event-type-workflow.input";
|
||||
import { UpdateEventTypeWorkflowDto } from "@/modules/workflows/inputs/update-event-type-workflow.input";
|
||||
import { WorkflowsInputService } from "@/modules/workflows/services/workflows.input.service";
|
||||
import { WorkflowsOutputService } from "@/modules/workflows/services/workflows.output.service";
|
||||
import { WorkflowsRepository } from "@/modules/workflows/workflows.repository";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class TeamEventTypeWorkflowsService {
|
||||
constructor(
|
||||
private readonly workflowsRepository: WorkflowsRepository,
|
||||
private readonly teamsVerifiedResourcesRepository: TeamsVerifiedResourcesRepository,
|
||||
private readonly workflowInputService: WorkflowsInputService,
|
||||
private readonly workflowOutputService: WorkflowsOutputService
|
||||
) {}
|
||||
|
||||
async getEventTypeTeamWorkflows(teamId: number, skip: number, take: number) {
|
||||
const workflows = await this.workflowsRepository.getEventTypeTeamWorkflows(teamId, skip, take);
|
||||
|
||||
return workflows.map((workflow) => {
|
||||
const output = this.workflowOutputService.toEventTypeOutputDto(workflow);
|
||||
|
||||
if (!output) {
|
||||
throw new BadRequestException(`Could not format workflow for response.`);
|
||||
}
|
||||
return output;
|
||||
});
|
||||
}
|
||||
|
||||
async getEventTypeTeamWorkflowById(teamId: number, workflowId: number) {
|
||||
const workflow = await this.workflowsRepository.getEventTypeTeamWorkflowById(teamId, workflowId);
|
||||
|
||||
if (!workflow) {
|
||||
throw new NotFoundException(`Workflow with ID ${workflowId} not found for team ${teamId}`);
|
||||
}
|
||||
|
||||
const output = this.workflowOutputService.toEventTypeOutputDto(workflow);
|
||||
|
||||
if (!output) {
|
||||
throw new BadRequestException(`Could not format workflow for response.`);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async createEventTypeTeamWorkflow(user: UserWithProfile, teamId: number, data: CreateEventTypeWorkflowDto) {
|
||||
const workflowHusk = await this.workflowsRepository.createTeamWorkflowHusk(teamId);
|
||||
const mappedData = await this.workflowInputService.mapEventTypeUpdateDtoToZodSchema(
|
||||
data,
|
||||
workflowHusk.id,
|
||||
teamId,
|
||||
workflowHusk
|
||||
);
|
||||
|
||||
const createdWorkflow = await this.workflowsRepository.updateEventTypeTeamWorkflow(
|
||||
user,
|
||||
teamId,
|
||||
workflowHusk.id,
|
||||
mappedData
|
||||
);
|
||||
if (!createdWorkflow) {
|
||||
throw new BadRequestException(`Could not create Workflow in team ${teamId}`);
|
||||
}
|
||||
|
||||
const output = this.workflowOutputService.toEventTypeOutputDto(createdWorkflow);
|
||||
|
||||
if (!output) {
|
||||
throw new BadRequestException(`Could not format workflow for response.`);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async updateEventTypeTeamWorkflow(
|
||||
user: UserWithProfile,
|
||||
teamId: number,
|
||||
workflowId: number,
|
||||
data: UpdateEventTypeWorkflowDto
|
||||
) {
|
||||
const currentWorkflow = await this.workflowsRepository.getEventTypeTeamWorkflowById(teamId, workflowId);
|
||||
|
||||
if (!currentWorkflow) {
|
||||
throw new NotFoundException(`Workflow with ID ${workflowId} not found for team ${teamId}`);
|
||||
}
|
||||
const mappedData = await this.workflowInputService.mapEventTypeUpdateDtoToZodSchema(
|
||||
data,
|
||||
workflowId,
|
||||
teamId,
|
||||
currentWorkflow
|
||||
);
|
||||
|
||||
const updatedWorkflow = await this.workflowsRepository.updateEventTypeTeamWorkflow(
|
||||
user,
|
||||
teamId,
|
||||
workflowId,
|
||||
mappedData
|
||||
);
|
||||
|
||||
if (!updatedWorkflow) {
|
||||
throw new BadRequestException(`Could not update Workflow with ID ${workflowId} in team ${teamId}`);
|
||||
}
|
||||
const output = this.workflowOutputService.toEventTypeOutputDto(updatedWorkflow);
|
||||
|
||||
if (!output) {
|
||||
throw new BadRequestException(`Could not format workflow for response.`);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async deleteTeamEventTypeWorkflow(teamId: number, workflowId: number) {
|
||||
return await this.workflowsRepository.deleteTeamWorkflowById(teamId, workflowId);
|
||||
}
|
||||
}
|
||||
+42
-20
@@ -1,14 +1,14 @@
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
|
||||
import { CreateWorkflowDto } from "@/modules/workflows/inputs/create-workflow.input";
|
||||
import { UpdateWorkflowDto } from "@/modules/workflows/inputs/update-workflow.input";
|
||||
import { CreateFormWorkflowDto } from "@/modules/workflows/inputs/create-form-workflow";
|
||||
import { UpdateFormWorkflowDto } from "@/modules/workflows/inputs/update-form-workflow.input";
|
||||
import { WorkflowsInputService } from "@/modules/workflows/services/workflows.input.service";
|
||||
import { WorkflowsOutputService } from "@/modules/workflows/services/workflows.output.service";
|
||||
import { WorkflowsRepository } from "@/modules/workflows/workflows.repository";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class TeamWorkflowsService {
|
||||
export class TeamRoutingFormWorkflowsService {
|
||||
constructor(
|
||||
private readonly workflowsRepository: WorkflowsRepository,
|
||||
private readonly teamsVerifiedResourcesRepository: TeamsVerifiedResourcesRepository,
|
||||
@@ -16,32 +16,44 @@ export class TeamWorkflowsService {
|
||||
private readonly workflowOutputService: WorkflowsOutputService
|
||||
) {}
|
||||
|
||||
async getTeamWorkflows(teamId: number, skip: number, take: number) {
|
||||
const workflows = await this.workflowsRepository.getTeamWorkflows(teamId, skip, take);
|
||||
async getRoutingFormTeamWorkflows(teamId: number, skip: number, take: number) {
|
||||
const workflows = await this.workflowsRepository.getRoutingFormTeamWorkflows(teamId, skip, take);
|
||||
|
||||
return workflows.map((workflow) => this.workflowOutputService.toOutputDto(workflow));
|
||||
return workflows.map((workflow) => {
|
||||
const output = this.workflowOutputService.toRoutingFormOutputDto(workflow);
|
||||
|
||||
if (!output) {
|
||||
throw new BadRequestException(`Could not format workflow for response.`);
|
||||
}
|
||||
return output;
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamWorkflowById(teamId: number, workflowId: number) {
|
||||
const workflow = await this.workflowsRepository.getTeamWorkflowById(teamId, workflowId);
|
||||
async getRoutingFormTeamWorkflowById(teamId: number, workflowId: number) {
|
||||
const workflow = await this.workflowsRepository.getRoutingFormTeamWorkflowById(teamId, workflowId);
|
||||
|
||||
if (!workflow) {
|
||||
throw new NotFoundException(`Workflow with ID ${workflowId} not found for team ${teamId}`);
|
||||
}
|
||||
|
||||
return this.workflowOutputService.toOutputDto(workflow);
|
||||
const output = this.workflowOutputService.toRoutingFormOutputDto(workflow);
|
||||
|
||||
if (!output) {
|
||||
throw new BadRequestException(`Could not format workflow for response.`);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async createTeamWorkflow(user: UserWithProfile, teamId: number, data: CreateWorkflowDto) {
|
||||
async createFormTeamWorkflow(user: UserWithProfile, teamId: number, data: CreateFormWorkflowDto) {
|
||||
const workflowHusk = await this.workflowsRepository.createTeamWorkflowHusk(teamId);
|
||||
const mappedData = await this.workflowInputService.mapUpdateDtoToZodUpdateSchema(
|
||||
const mappedData = await this.workflowInputService.mapFormUpdateDtoToZodSchema(
|
||||
data,
|
||||
workflowHusk.id,
|
||||
teamId,
|
||||
workflowHusk
|
||||
);
|
||||
|
||||
const createdWorkflow = await this.workflowsRepository.updateTeamWorkflow(
|
||||
const createdWorkflow = await this.workflowsRepository.updateRoutingFormTeamWorkflow(
|
||||
user,
|
||||
teamId,
|
||||
workflowHusk.id,
|
||||
@@ -51,28 +63,33 @@ export class TeamWorkflowsService {
|
||||
throw new BadRequestException(`Could not create Workflow in team ${teamId}`);
|
||||
}
|
||||
|
||||
return this.workflowOutputService.toOutputDto(createdWorkflow);
|
||||
const output = this.workflowOutputService.toRoutingFormOutputDto(createdWorkflow);
|
||||
|
||||
if (!output) {
|
||||
throw new BadRequestException(`Could not format workflow for response.`);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async updateTeamWorkflow(
|
||||
async updateFormTeamWorkflow(
|
||||
user: UserWithProfile,
|
||||
teamId: number,
|
||||
workflowId: number,
|
||||
data: UpdateWorkflowDto
|
||||
data: UpdateFormWorkflowDto
|
||||
) {
|
||||
const currentWorkflow = await this.workflowsRepository.getTeamWorkflowById(teamId, workflowId);
|
||||
const currentWorkflow = await this.workflowsRepository.getRoutingFormTeamWorkflowById(teamId, workflowId);
|
||||
|
||||
if (!currentWorkflow) {
|
||||
throw new NotFoundException(`Workflow with ID ${workflowId} not found for team ${teamId}`);
|
||||
}
|
||||
const mappedData = await this.workflowInputService.mapUpdateDtoToZodUpdateSchema(
|
||||
const mappedData = await this.workflowInputService.mapFormUpdateDtoToZodSchema(
|
||||
data,
|
||||
workflowId,
|
||||
teamId,
|
||||
currentWorkflow
|
||||
);
|
||||
|
||||
const updatedWorkflow = await this.workflowsRepository.updateTeamWorkflow(
|
||||
const updatedWorkflow = await this.workflowsRepository.updateRoutingFormTeamWorkflow(
|
||||
user,
|
||||
teamId,
|
||||
workflowId,
|
||||
@@ -83,10 +100,15 @@ export class TeamWorkflowsService {
|
||||
throw new BadRequestException(`Could not update Workflow with ID ${workflowId} in team ${teamId}`);
|
||||
}
|
||||
|
||||
return this.workflowOutputService.toOutputDto(updatedWorkflow);
|
||||
const output = this.workflowOutputService.toRoutingFormOutputDto(updatedWorkflow);
|
||||
|
||||
if (!output) {
|
||||
throw new BadRequestException(`Could not format workflow for response.`);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async deleteTeamWorkflow(teamId: number, workflowId: number) {
|
||||
async deleteTeamRoutingFormWorkflow(teamId: number, workflowId: number) {
|
||||
return await this.workflowsRepository.deleteTeamWorkflowById(teamId, workflowId);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
|
||||
import {
|
||||
UpdateWorkflowDto,
|
||||
UpdateWorkflowStepDto,
|
||||
UpdateEmailAttendeeWorkflowStepDto,
|
||||
UpdateEmailAddressWorkflowStepDto,
|
||||
UpdateEmailHostWorkflowStepDto,
|
||||
} from "@/modules/workflows/inputs/update-workflow.input";
|
||||
import { UpdateEventTypeWorkflowDto } from "@/modules/workflows/inputs/update-event-type-workflow.input";
|
||||
import { UpdateFormWorkflowDto } from "@/modules/workflows/inputs/update-form-workflow.input";
|
||||
import { WorkflowType } from "@/modules/workflows/workflows.repository";
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
|
||||
@@ -21,14 +16,13 @@ import {
|
||||
STEP_ACTIONS_TO_ENUM,
|
||||
TemplateType,
|
||||
TextWorkflowMessageDto,
|
||||
UpdateWorkflowStepDto,
|
||||
WHATSAPP_ATTENDEE,
|
||||
WHATSAPP_NUMBER,
|
||||
WorkflowPhoneNumberStepDto,
|
||||
WorkflowPhoneWhatsAppNumberStepDto,
|
||||
} from "../inputs/workflow-step.input";
|
||||
import {
|
||||
OnAfterEventTriggerDto,
|
||||
OnBeforeEventTriggerDto,
|
||||
OffsetTriggerDTOInstances,
|
||||
OffsetTriggerDTOInstancesType,
|
||||
TIME_UNIT_TO_ENUM,
|
||||
WORKFLOW_TRIGGER_TO_ENUM,
|
||||
} from "../inputs/workflow-trigger.input";
|
||||
@@ -37,6 +31,37 @@ import {
|
||||
export class WorkflowsInputService {
|
||||
constructor(private readonly teamsVerifiedResourcesRepository: TeamsVerifiedResourcesRepository) {}
|
||||
|
||||
private _isOffsetTrigger(
|
||||
trigger: UpdateEventTypeWorkflowDto["trigger"] | UpdateFormWorkflowDto["trigger"]
|
||||
): trigger is OffsetTriggerDTOInstancesType {
|
||||
return OffsetTriggerDTOInstances.some((Instance) => trigger instanceof Instance);
|
||||
}
|
||||
|
||||
private async _getTeamPhoneNumberFromVerifiedId(teamId: number, verifiedPhoneId: number) {
|
||||
const phoneResource = await this.teamsVerifiedResourcesRepository.getTeamVerifiedPhoneNumberById(
|
||||
verifiedPhoneId,
|
||||
teamId
|
||||
);
|
||||
|
||||
if (!phoneResource?.phoneNumber) {
|
||||
throw new BadRequestException("Invalid Verified Phone Id.");
|
||||
}
|
||||
|
||||
return phoneResource.phoneNumber;
|
||||
}
|
||||
|
||||
private async _getTeamEmailFromVerifiedId(teamId: number, verifiedEmailId: number) {
|
||||
const emailResource = await this.teamsVerifiedResourcesRepository.getTeamVerifiedEmailById(
|
||||
verifiedEmailId,
|
||||
teamId
|
||||
);
|
||||
if (!emailResource?.email) {
|
||||
throw new BadRequestException("Invalid Verified Email Id.");
|
||||
}
|
||||
|
||||
return emailResource.email;
|
||||
}
|
||||
|
||||
private async mapUpdateWorkflowStepToZodUpdateSchema(
|
||||
stepDto: UpdateWorkflowStepDto,
|
||||
index: number,
|
||||
@@ -45,61 +70,42 @@ export class WorkflowsInputService {
|
||||
) {
|
||||
let reminderBody: string | null = null;
|
||||
let sendTo: string | null = null;
|
||||
let phoneRequired: boolean | null = null;
|
||||
const html = stepDto.message instanceof HtmlWorkflowMessageDto ? stepDto.message.html : null;
|
||||
const text = stepDto.message instanceof TextWorkflowMessageDto ? stepDto.message.text : null;
|
||||
const includeCalendarEvent =
|
||||
stepDto instanceof UpdateEmailAddressWorkflowStepDto ||
|
||||
stepDto instanceof UpdateEmailAttendeeWorkflowStepDto ||
|
||||
stepDto instanceof UpdateEmailHostWorkflowStepDto
|
||||
? stepDto.includeCalendarEvent
|
||||
: false;
|
||||
let includeCalendarEvent = false;
|
||||
|
||||
switch (stepDto.action) {
|
||||
case EMAIL_HOST:
|
||||
case EMAIL_ATTENDEE:
|
||||
case EMAIL_ADDRESS:
|
||||
reminderBody = html ?? null;
|
||||
includeCalendarEvent = stepDto.includeCalendarEvent;
|
||||
break;
|
||||
case SMS_ATTENDEE:
|
||||
phoneRequired = stepDto.phoneRequired ?? false;
|
||||
break;
|
||||
case SMS_NUMBER:
|
||||
break;
|
||||
case WHATSAPP_ATTENDEE:
|
||||
phoneRequired = stepDto.phoneRequired ?? false;
|
||||
break;
|
||||
case WHATSAPP_NUMBER:
|
||||
reminderBody = text ?? null;
|
||||
break;
|
||||
}
|
||||
if (stepDto.action === EMAIL_ADDRESS) {
|
||||
if (stepDto.verifiedEmailId) {
|
||||
const emailResource = await this.teamsVerifiedResourcesRepository.getTeamVerifiedEmailById(
|
||||
stepDto.verifiedEmailId,
|
||||
teamId
|
||||
);
|
||||
if (!emailResource?.email) {
|
||||
throw new BadRequestException("Invalid Verified Email Id.");
|
||||
}
|
||||
sendTo = emailResource.email;
|
||||
sendTo = await this._getTeamEmailFromVerifiedId(teamId, stepDto.verifiedEmailId);
|
||||
}
|
||||
} else if (stepDto.action === SMS_NUMBER || stepDto.action === WHATSAPP_NUMBER) {
|
||||
if (
|
||||
stepDto instanceof WorkflowPhoneNumberStepDto ||
|
||||
stepDto instanceof WorkflowPhoneWhatsAppNumberStepDto
|
||||
) {
|
||||
if (stepDto.verifiedPhoneId) {
|
||||
const phoneResource = await this.teamsVerifiedResourcesRepository.getTeamVerifiedPhoneNumberById(
|
||||
stepDto.verifiedPhoneId,
|
||||
teamId
|
||||
);
|
||||
|
||||
if (!phoneResource?.phoneNumber) {
|
||||
throw new BadRequestException("Invalid Verified Phone Id.");
|
||||
}
|
||||
|
||||
sendTo = phoneResource.phoneNumber;
|
||||
}
|
||||
if (stepDto.verifiedPhoneId) {
|
||||
sendTo = await this._getTeamPhoneNumberFromVerifiedId(teamId, stepDto.verifiedPhoneId);
|
||||
}
|
||||
}
|
||||
|
||||
const actionForZod = STEP_ACTIONS_TO_ENUM[stepDto.action];
|
||||
const templateForZod = stepDto.template as unknown as Uppercase<TemplateType>;
|
||||
const templateForZod = stepDto?.template?.toUpperCase() as unknown as Uppercase<TemplateType>;
|
||||
|
||||
return {
|
||||
id: stepDto.id ?? -(index + 1),
|
||||
@@ -110,19 +116,19 @@ export class WorkflowsInputService {
|
||||
reminderBody: reminderBody,
|
||||
emailSubject: stepDto.message.subject ?? null,
|
||||
template: templateForZod,
|
||||
numberRequired: null,
|
||||
numberRequired: phoneRequired,
|
||||
sender: stepDto.sender ?? null,
|
||||
senderName: stepDto.sender ?? null,
|
||||
includeCalendarEvent: includeCalendarEvent,
|
||||
};
|
||||
}
|
||||
|
||||
async mapUpdateDtoToZodUpdateSchema(
|
||||
updateDto: UpdateWorkflowDto,
|
||||
workflowIdToUse: number,
|
||||
private async _mapCommonWorkflowProperties(
|
||||
updateDto: UpdateEventTypeWorkflowDto | UpdateFormWorkflowDto,
|
||||
currentData: WorkflowType,
|
||||
teamId: number,
|
||||
currentData: WorkflowType
|
||||
): Promise<TUpdateInputSchema> {
|
||||
workflowIdToUse: number
|
||||
) {
|
||||
const mappedSteps = updateDto?.steps
|
||||
? await Promise.all(
|
||||
updateDto.steps.map(async (stepDto: UpdateWorkflowStepDto, index: number) =>
|
||||
@@ -134,28 +140,85 @@ export class WorkflowsInputService {
|
||||
const triggerForZod = updateDto?.trigger?.type
|
||||
? WORKFLOW_TRIGGER_TO_ENUM[updateDto?.trigger?.type]
|
||||
: currentData.trigger;
|
||||
const timeUnitForZod =
|
||||
updateDto.trigger instanceof OnBeforeEventTriggerDto ||
|
||||
updateDto.trigger instanceof OnAfterEventTriggerDto
|
||||
? updateDto?.trigger?.offset?.unit ?? currentData.timeUnit ?? null
|
||||
: undefined;
|
||||
|
||||
const timeUnitForZod = this._isOffsetTrigger(updateDto.trigger)
|
||||
? updateDto?.trigger?.offset?.unit ?? currentData.timeUnit ?? null
|
||||
: undefined;
|
||||
|
||||
const time = this._isOffsetTrigger(updateDto.trigger)
|
||||
? updateDto?.trigger?.offset?.value ?? currentData?.time ?? null
|
||||
: null;
|
||||
|
||||
const timeUnit = timeUnitForZod ? TIME_UNIT_TO_ENUM[timeUnitForZod] : null;
|
||||
|
||||
return { mappedSteps, triggerForZod, time, timeUnit };
|
||||
}
|
||||
|
||||
async mapEventTypeUpdateDtoToZodSchema(
|
||||
updateDto: UpdateEventTypeWorkflowDto,
|
||||
workflowIdToUse: number,
|
||||
teamId: number,
|
||||
currentData: WorkflowType
|
||||
): Promise<TUpdateInputSchema> {
|
||||
const { mappedSteps, triggerForZod, time, timeUnit } = await this._mapCommonWorkflowProperties(
|
||||
updateDto,
|
||||
currentData,
|
||||
teamId,
|
||||
workflowIdToUse
|
||||
);
|
||||
|
||||
const updateData: TUpdateInputSchema = {
|
||||
id: workflowIdToUse,
|
||||
name: updateDto.name ?? currentData.name,
|
||||
activeOn:
|
||||
steps: mappedSteps,
|
||||
trigger: triggerForZod,
|
||||
time: time,
|
||||
timeUnit: timeUnit,
|
||||
|
||||
// Event-type specific logic
|
||||
activeOnEventTypeIds:
|
||||
updateDto?.activation?.activeOnEventTypeIds ??
|
||||
currentData?.activeOn.map((active) => active.eventTypeId) ??
|
||||
[],
|
||||
isActiveOnAll: updateDto?.activation?.isActiveOnAllEventTypes ?? currentData.isActiveOnAll ?? false,
|
||||
|
||||
// Explicitly set form-related fields to their default/empty state
|
||||
activeOnRoutingFormIds: [],
|
||||
} as const satisfies TUpdateInputSchema;
|
||||
|
||||
return updateData;
|
||||
}
|
||||
|
||||
async mapFormUpdateDtoToZodSchema(
|
||||
updateDto: UpdateFormWorkflowDto,
|
||||
workflowIdToUse: number,
|
||||
teamId: number,
|
||||
currentData: WorkflowType
|
||||
): Promise<TUpdateInputSchema> {
|
||||
const { mappedSteps, triggerForZod, time, timeUnit } = await this._mapCommonWorkflowProperties(
|
||||
updateDto,
|
||||
currentData,
|
||||
teamId,
|
||||
workflowIdToUse
|
||||
);
|
||||
|
||||
const updateData: TUpdateInputSchema = {
|
||||
id: workflowIdToUse,
|
||||
name: updateDto.name ?? currentData.name,
|
||||
steps: mappedSteps,
|
||||
trigger: triggerForZod,
|
||||
time:
|
||||
updateDto.trigger instanceof OnBeforeEventTriggerDto ||
|
||||
updateDto.trigger instanceof OnAfterEventTriggerDto
|
||||
? updateDto?.trigger?.offset?.value ?? currentData?.time ?? null
|
||||
: null,
|
||||
timeUnit: timeUnitForZod ? TIME_UNIT_TO_ENUM[timeUnitForZod] : null,
|
||||
isActiveOnAll: updateDto?.activation?.isActiveOnAllEventTypes ?? currentData.isActiveOnAll ?? false,
|
||||
time: time,
|
||||
timeUnit: timeUnit,
|
||||
|
||||
// Form-specific logic
|
||||
activeOnRoutingFormIds:
|
||||
updateDto?.activation?.activeOnRoutingFormIds ??
|
||||
currentData?.activeOnRoutingForms.map((active) => active.routingFormId) ??
|
||||
[],
|
||||
isActiveOnAll: updateDto?.activation?.isActiveOnAllRoutingForms ?? currentData.isActiveOnAll ?? false,
|
||||
|
||||
// Explicitly set event-type-related fields to their default/empty state
|
||||
activeOnEventTypeIds: [],
|
||||
} as const satisfies TUpdateInputSchema;
|
||||
|
||||
return updateData;
|
||||
|
||||
@@ -1,48 +1,223 @@
|
||||
import { WorkflowActivationDto, TriggerDtoType } from "@/modules/workflows/inputs/create-workflow.input";
|
||||
import { WorkflowOutput, WorkflowStepOutputDto } from "@/modules/workflows/outputs/workflow.output";
|
||||
import { WorkflowActivationDto } from "@/modules/workflows/inputs/create-event-type-workflow.input";
|
||||
import { WorkflowFormActivationDto } from "@/modules/workflows/inputs/create-form-workflow";
|
||||
import {
|
||||
EventTypeWorkflowStepOutputDto,
|
||||
EventTypeWorkflowOutput,
|
||||
} from "@/modules/workflows/outputs/event-type-workflow.output";
|
||||
import {
|
||||
RoutingFormWorkflowOutput,
|
||||
RoutingFormWorkflowStepOutputDto,
|
||||
} from "@/modules/workflows/outputs/routing-form-workflow.output";
|
||||
import { WorkflowType } from "@/modules/workflows/workflows.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
ATTENDEE,
|
||||
CAL_AI_PHONE_CALL,
|
||||
EMAIL,
|
||||
EMAIL_ADDRESS,
|
||||
EMAIL_ATTENDEE,
|
||||
EMAIL_HOST,
|
||||
ENUM_TO_STEP_ACTIONS,
|
||||
ENUM_TO_TEMPLATES,
|
||||
FORM_ALLOWED_STEP_ACTIONS,
|
||||
FormAllowedStepAction,
|
||||
HOST,
|
||||
PHONE_NUMBER,
|
||||
RecipientType,
|
||||
SMS_ATTENDEE,
|
||||
SMS_NUMBER,
|
||||
StepAction,
|
||||
WHATSAPP_ATTENDEE,
|
||||
WHATSAPP_NUMBER,
|
||||
} from "../inputs/workflow-step.input";
|
||||
import {
|
||||
AFTER_EVENT,
|
||||
AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
|
||||
AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
BEFORE_EVENT,
|
||||
ENUM_TO_TIME_UNIT,
|
||||
ENUM_TO_WORKFLOW_TRIGGER,
|
||||
HOUR,
|
||||
OnAfterCalVideoGuestsNoShowTriggerDto,
|
||||
OnAfterCalVideoHostsNoShowTriggerDto,
|
||||
OnAfterEventTriggerDto,
|
||||
OnBeforeEventTriggerDto,
|
||||
OnCancelTriggerDto,
|
||||
OnCreationTriggerDto,
|
||||
OnFormSubmittedTriggerDto,
|
||||
OnFormSubmittedNoEventTriggerDto,
|
||||
OnNoShowUpdateTriggerDto,
|
||||
OnPaidTriggerDto,
|
||||
OnPaymentInitiatedTriggerDto,
|
||||
OnRejectedTriggerDto,
|
||||
OnRequestedTriggerDto,
|
||||
OnRescheduleTriggerDto,
|
||||
WORKFLOW_TRIGGER_TO_ENUM,
|
||||
FORM_WORKFLOW_TRIGGER_TYPES,
|
||||
ENUM_ROUTING_FORM_WORFLOW_TRIGGERS,
|
||||
ENUM_OFFSET_WORFLOW_TRIGGERS,
|
||||
} from "../inputs/workflow-trigger.input";
|
||||
|
||||
export type TriggerDtoType =
|
||||
| OnAfterEventTriggerDto
|
||||
| OnBeforeEventTriggerDto
|
||||
| OnCreationTriggerDto
|
||||
| OnRescheduleTriggerDto
|
||||
| OnCancelTriggerDto
|
||||
| OnAfterCalVideoGuestsNoShowTriggerDto
|
||||
| OnFormSubmittedTriggerDto
|
||||
| OnFormSubmittedNoEventTriggerDto
|
||||
| OnRejectedTriggerDto
|
||||
| OnRequestedTriggerDto
|
||||
| OnPaymentInitiatedTriggerDto
|
||||
| OnPaidTriggerDto
|
||||
| OnNoShowUpdateTriggerDto
|
||||
| OnAfterCalVideoHostsNoShowTriggerDto;
|
||||
|
||||
export type TriggerEventTypeDtoType =
|
||||
| OnAfterEventTriggerDto
|
||||
| OnBeforeEventTriggerDto
|
||||
| OnCreationTriggerDto
|
||||
| OnRescheduleTriggerDto
|
||||
| OnCancelTriggerDto
|
||||
| OnAfterCalVideoGuestsNoShowTriggerDto
|
||||
| OnRejectedTriggerDto
|
||||
| OnRequestedTriggerDto
|
||||
| OnPaymentInitiatedTriggerDto
|
||||
| OnPaidTriggerDto
|
||||
| OnNoShowUpdateTriggerDto
|
||||
| OnAfterCalVideoHostsNoShowTriggerDto;
|
||||
|
||||
type StepConfig = {
|
||||
recipient: RecipientType;
|
||||
messageKey: "html" | "text";
|
||||
setsCustomRecipient: boolean;
|
||||
requiresPhone: boolean;
|
||||
};
|
||||
|
||||
const ACTION_CONFIG_MAP = {
|
||||
[EMAIL_HOST]: {
|
||||
recipient: HOST,
|
||||
messageKey: "html",
|
||||
setsCustomRecipient: false,
|
||||
requiresPhone: false,
|
||||
} satisfies StepConfig,
|
||||
[EMAIL_ATTENDEE]: {
|
||||
recipient: ATTENDEE,
|
||||
messageKey: "html",
|
||||
setsCustomRecipient: false,
|
||||
requiresPhone: false,
|
||||
},
|
||||
[SMS_ATTENDEE]: {
|
||||
recipient: ATTENDEE,
|
||||
messageKey: "text",
|
||||
setsCustomRecipient: false,
|
||||
requiresPhone: true,
|
||||
},
|
||||
[WHATSAPP_ATTENDEE]: {
|
||||
recipient: ATTENDEE,
|
||||
messageKey: "text",
|
||||
setsCustomRecipient: false,
|
||||
requiresPhone: true,
|
||||
},
|
||||
[EMAIL_ADDRESS]: { recipient: EMAIL, messageKey: "html", setsCustomRecipient: true, requiresPhone: false },
|
||||
[SMS_NUMBER]: {
|
||||
recipient: PHONE_NUMBER,
|
||||
messageKey: "text",
|
||||
setsCustomRecipient: true,
|
||||
requiresPhone: false,
|
||||
},
|
||||
[WHATSAPP_NUMBER]: {
|
||||
recipient: PHONE_NUMBER,
|
||||
messageKey: "text",
|
||||
setsCustomRecipient: true,
|
||||
requiresPhone: false,
|
||||
},
|
||||
[CAL_AI_PHONE_CALL]: {
|
||||
recipient: PHONE_NUMBER,
|
||||
messageKey: "text",
|
||||
setsCustomRecipient: true,
|
||||
requiresPhone: false,
|
||||
},
|
||||
} satisfies Record<StepAction, StepConfig>;
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowsOutputService {
|
||||
toOutputDto(workflow: WorkflowType): WorkflowOutput {
|
||||
const activation: WorkflowActivationDto = {
|
||||
isActiveOnAllEventTypes: workflow.isActiveOnAll,
|
||||
activeOnEventTypeIds: workflow.activeOn?.map((relation) => relation.eventTypeId) ?? [],
|
||||
_isFormAllowedStepAction(action: StepAction): action is FormAllowedStepAction {
|
||||
return FORM_ALLOWED_STEP_ACTIONS.some((formAction) => formAction === action);
|
||||
}
|
||||
_isFormAllowedTrigger(
|
||||
trigger: WorkflowType["trigger"]
|
||||
): trigger is (typeof ENUM_ROUTING_FORM_WORFLOW_TRIGGERS)[number] {
|
||||
return FORM_WORKFLOW_TRIGGER_TYPES.some(
|
||||
(formTrigger) => WORKFLOW_TRIGGER_TO_ENUM[formTrigger] === trigger
|
||||
);
|
||||
}
|
||||
|
||||
private _isOffsetTrigger(
|
||||
trigger: WorkflowType["trigger"]
|
||||
): trigger is (typeof ENUM_OFFSET_WORFLOW_TRIGGERS)[number] {
|
||||
return ENUM_OFFSET_WORFLOW_TRIGGERS.some((offsetTrigger) => offsetTrigger === trigger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a single workflow step from the database entity to its DTO representation.
|
||||
* @param step The workflow step object from the database.
|
||||
* @returns An EventTypeWorkflowStepOutputDto.
|
||||
*/
|
||||
mapStep(step: WorkflowType["steps"][number], _discriminator: "event-type"): EventTypeWorkflowStepOutputDto;
|
||||
mapStep(
|
||||
step: WorkflowType["steps"][number],
|
||||
_discriminator: "routing-form"
|
||||
): RoutingFormWorkflowStepOutputDto;
|
||||
mapStep(
|
||||
step: WorkflowType["steps"][number],
|
||||
_discriminator: "event-type" | "routing-form"
|
||||
): EventTypeWorkflowStepOutputDto | RoutingFormWorkflowStepOutputDto {
|
||||
const action = ENUM_TO_STEP_ACTIONS[step.action];
|
||||
const config = ACTION_CONFIG_MAP[action] || {
|
||||
recipient: ATTENDEE,
|
||||
setsCustomRecipient: false,
|
||||
requiresPhone: false,
|
||||
};
|
||||
|
||||
const trigger: TriggerDtoType =
|
||||
workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[BEFORE_EVENT] ||
|
||||
workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[AFTER_EVENT] ||
|
||||
workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[AFTER_GUESTS_CAL_VIDEO_NO_SHOW] ||
|
||||
workflow.trigger === WORKFLOW_TRIGGER_TO_ENUM[AFTER_HOSTS_CAL_VIDEO_NO_SHOW]
|
||||
const customRecipient = step.sendTo ?? "";
|
||||
const reminderBody = step.reminderBody ?? "";
|
||||
|
||||
const baseAction = {
|
||||
id: step.id,
|
||||
stepNumber: step.stepNumber,
|
||||
template: ENUM_TO_TEMPLATES[step.template],
|
||||
recipient: config.recipient,
|
||||
sender: step.sender ?? "Default Sender",
|
||||
includeCalendarEvent: step.includeCalendarEvent,
|
||||
phoneRequired: config.requiresPhone ? step.numberRequired ?? false : undefined,
|
||||
email: config.recipient === EMAIL ? customRecipient : "",
|
||||
phone: config.recipient === PHONE_NUMBER ? customRecipient : "",
|
||||
message: {
|
||||
subject: step.emailSubject ?? "",
|
||||
html: config.messageKey === "html" ? reminderBody : undefined,
|
||||
text: config.messageKey === "text" ? reminderBody : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
return this._isFormAllowedStepAction(action)
|
||||
? ({
|
||||
...baseAction,
|
||||
action: action,
|
||||
} satisfies RoutingFormWorkflowStepOutputDto)
|
||||
: ({
|
||||
...baseAction,
|
||||
action: action,
|
||||
} satisfies EventTypeWorkflowStepOutputDto);
|
||||
}
|
||||
|
||||
toRoutingFormOutputDto(workflow: WorkflowType): RoutingFormWorkflowOutput | void {
|
||||
if (workflow.type === "ROUTING_FORM" && this._isFormAllowedTrigger(workflow.trigger)) {
|
||||
const activation: WorkflowFormActivationDto = {
|
||||
isActiveOnAllRoutingForms: workflow.isActiveOnAll,
|
||||
activeOnRoutingFormIds:
|
||||
workflow.activeOnRoutingForms?.map((relation) => relation.routingFormId) ?? [],
|
||||
};
|
||||
|
||||
const trigger: TriggerDtoType = this._isOffsetTrigger(workflow.trigger)
|
||||
? {
|
||||
type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger],
|
||||
offset: {
|
||||
@@ -52,68 +227,50 @@ export class WorkflowsOutputService {
|
||||
}
|
||||
: { type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger] };
|
||||
|
||||
const steps: WorkflowStepOutputDto[] = workflow.steps.map((step) => {
|
||||
let recipient: RecipientType;
|
||||
let email = "";
|
||||
let phone = "";
|
||||
let text;
|
||||
let html;
|
||||
switch (ENUM_TO_STEP_ACTIONS[step.action]) {
|
||||
case EMAIL_HOST:
|
||||
recipient = HOST;
|
||||
html = step.reminderBody ?? "";
|
||||
break;
|
||||
case EMAIL_ATTENDEE:
|
||||
html = step.reminderBody ?? "";
|
||||
recipient = ATTENDEE;
|
||||
break;
|
||||
case SMS_ATTENDEE:
|
||||
text = step.reminderBody ?? "";
|
||||
recipient = ATTENDEE;
|
||||
break;
|
||||
case WHATSAPP_ATTENDEE:
|
||||
text = step.reminderBody ?? "";
|
||||
recipient = ATTENDEE;
|
||||
break;
|
||||
case EMAIL_ADDRESS:
|
||||
html = step.reminderBody ?? "";
|
||||
recipient = EMAIL;
|
||||
email = step.sendTo ?? "";
|
||||
break;
|
||||
case SMS_NUMBER:
|
||||
case WHATSAPP_NUMBER:
|
||||
text = step.reminderBody ?? "";
|
||||
recipient = PHONE_NUMBER;
|
||||
phone = step.sendTo ?? "";
|
||||
break;
|
||||
default:
|
||||
recipient = ATTENDEE;
|
||||
}
|
||||
const steps: RoutingFormWorkflowStepOutputDto[] = workflow.steps.map((step) => {
|
||||
return this.mapStep(step, "routing-form");
|
||||
});
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
stepNumber: step.stepNumber,
|
||||
action: ENUM_TO_STEP_ACTIONS[step.action],
|
||||
recipient: recipient,
|
||||
email,
|
||||
phone,
|
||||
template: ENUM_TO_TEMPLATES[step.template],
|
||||
includeCalendarEvent: step.includeCalendarEvent,
|
||||
sender: step.sender ?? "Default Sender",
|
||||
message: {
|
||||
subject: step.emailSubject ?? "",
|
||||
text,
|
||||
html,
|
||||
},
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
activation: activation,
|
||||
trigger: trigger,
|
||||
steps: steps.sort((stepA, stepB) => stepA.stepNumber - stepB.stepNumber),
|
||||
type: "routing-form",
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
activation: activation,
|
||||
trigger: trigger,
|
||||
steps: steps.sort((stepA, stepB) => stepA.stepNumber - stepB.stepNumber),
|
||||
};
|
||||
toEventTypeOutputDto(workflow: WorkflowType): EventTypeWorkflowOutput | void {
|
||||
if (workflow.type === "EVENT_TYPE" && !this._isFormAllowedTrigger(workflow.trigger)) {
|
||||
const activation: WorkflowActivationDto = {
|
||||
isActiveOnAllEventTypes: workflow.isActiveOnAll,
|
||||
activeOnEventTypeIds: workflow.activeOn?.map((relation) => relation.eventTypeId) ?? [],
|
||||
};
|
||||
|
||||
const trigger: TriggerEventTypeDtoType = this._isOffsetTrigger(workflow.trigger)
|
||||
? {
|
||||
type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger],
|
||||
offset: {
|
||||
value: workflow.time ?? 1,
|
||||
unit: workflow.timeUnit ? ENUM_TO_TIME_UNIT[workflow.timeUnit] : HOUR,
|
||||
},
|
||||
}
|
||||
: { type: ENUM_TO_WORKFLOW_TRIGGER[workflow.trigger] };
|
||||
|
||||
const steps: EventTypeWorkflowStepOutputDto[] = workflow.steps.map((step) => {
|
||||
return this.mapStep(step, "event-type");
|
||||
});
|
||||
|
||||
return {
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
activation: activation,
|
||||
trigger: trigger,
|
||||
steps: steps.sort((stepA, stepB) => stepA.stepNumber - stepB.stepNumber),
|
||||
type: "event-type",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@ import { updateWorkflow } from "@calcom/platform-libraries/workflows";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { Workflow, WorkflowStep } from "@calcom/prisma/client";
|
||||
|
||||
export type WorkflowType = Workflow & { activeOn: { eventTypeId: number }[]; steps: WorkflowStep[] };
|
||||
export type WorkflowType = Workflow & {
|
||||
activeOn: { eventTypeId: number }[];
|
||||
steps: WorkflowStep[];
|
||||
activeOnRoutingForms: { routingFormId: string }[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowsRepository {
|
||||
@@ -19,29 +23,68 @@ export class WorkflowsRepository {
|
||||
return await this.dbWrite.prisma.workflow.delete({ where: { id: workflowId, teamId } });
|
||||
}
|
||||
|
||||
async getTeamWorkflowById(teamId: number, id: number): Promise<WorkflowType | null> {
|
||||
async getEventTypeTeamWorkflowById(teamId: number, id: number): Promise<WorkflowType | null> {
|
||||
const workflow = await this.dbRead.prisma.workflow.findUnique({
|
||||
where: {
|
||||
id: id,
|
||||
teamId: teamId,
|
||||
type: "EVENT_TYPE",
|
||||
},
|
||||
include: {
|
||||
steps: true,
|
||||
activeOn: { select: { eventTypeId: true } },
|
||||
activeOnRoutingForms: { select: { routingFormId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
async getTeamWorkflows(teamId: number, skip: number, take: number): Promise<WorkflowType[]> {
|
||||
const workflows = await this.dbRead.prisma.workflow.findMany({
|
||||
async getRoutingFormTeamWorkflowById(teamId: number, id: number): Promise<WorkflowType | null> {
|
||||
const workflow = await this.dbRead.prisma.workflow.findUnique({
|
||||
where: {
|
||||
id: id,
|
||||
teamId: teamId,
|
||||
type: "ROUTING_FORM",
|
||||
},
|
||||
include: {
|
||||
steps: true,
|
||||
activeOn: { select: { eventTypeId: true } },
|
||||
activeOnRoutingForms: { select: { routingFormId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
async getEventTypeTeamWorkflows(teamId: number, skip: number, take: number): Promise<WorkflowType[]> {
|
||||
const workflows = await this.dbRead.prisma.workflow.findMany({
|
||||
where: {
|
||||
teamId: teamId,
|
||||
type: "EVENT_TYPE",
|
||||
},
|
||||
include: {
|
||||
steps: true,
|
||||
activeOn: { select: { eventTypeId: true } },
|
||||
activeOnRoutingForms: { select: { routingFormId: true } },
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
|
||||
return workflows;
|
||||
}
|
||||
|
||||
async getRoutingFormTeamWorkflows(teamId: number, skip: number, take: number): Promise<WorkflowType[]> {
|
||||
const workflows = await this.dbRead.prisma.workflow.findMany({
|
||||
where: {
|
||||
teamId: teamId,
|
||||
type: "ROUTING_FORM",
|
||||
},
|
||||
include: {
|
||||
steps: true,
|
||||
activeOn: { select: { eventTypeId: true } },
|
||||
activeOnRoutingForms: { select: { routingFormId: true } },
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
@@ -59,11 +102,11 @@ export class WorkflowsRepository {
|
||||
timeUnit: TimeUnit.HOUR,
|
||||
teamId,
|
||||
},
|
||||
include: { activeOn: true, steps: true },
|
||||
include: { activeOn: true, steps: true, activeOnRoutingForms: true },
|
||||
});
|
||||
}
|
||||
|
||||
async updateTeamWorkflow(
|
||||
async updateRoutingFormTeamWorkflow(
|
||||
user: UserWithProfile,
|
||||
teamId: number,
|
||||
workflowId: number,
|
||||
@@ -77,7 +120,25 @@ export class WorkflowsRepository {
|
||||
input: data,
|
||||
});
|
||||
|
||||
const workflow = await this.getTeamWorkflowById(teamId, workflowId);
|
||||
const workflow = await this.getRoutingFormTeamWorkflowById(teamId, workflowId);
|
||||
return workflow;
|
||||
}
|
||||
|
||||
async updateEventTypeTeamWorkflow(
|
||||
user: UserWithProfile,
|
||||
teamId: number,
|
||||
workflowId: number,
|
||||
data: TUpdateInputSchema
|
||||
) {
|
||||
await updateWorkflow({
|
||||
ctx: {
|
||||
user: { ...user, locale: user?.locale ?? "en" },
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
},
|
||||
input: data,
|
||||
});
|
||||
|
||||
const workflow = await this.getEventTypeTeamWorkflowById(teamId, workflowId);
|
||||
return workflow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { DecoyBookingSuccessCard } from "@calcom/features/bookings/Booker/components/DecoyBookingSuccessCard";
|
||||
import { useDecoyBooking } from "@calcom/features/bookings/Booker/components/hooks/useDecoyBooking";
|
||||
|
||||
export default function BookingSuccessful() {
|
||||
const params = useParams();
|
||||
|
||||
const uid = params?.uid as string;
|
||||
const bookingData = useDecoyBooking(uid);
|
||||
|
||||
if (!bookingData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { booking } = bookingData;
|
||||
|
||||
// Format the data for the BookingSuccessCard
|
||||
const startTime = booking.startTime ? dayjs(booking.startTime) : null;
|
||||
const endTime = booking.endTime ? dayjs(booking.endTime) : null;
|
||||
const timeZone = booking.booker?.timeZone || booking.host?.timeZone || dayjs.tz.guess();
|
||||
|
||||
const formattedDate = startTime ? startTime.tz(timeZone).format("dddd, MMMM D, YYYY") : "";
|
||||
const formattedTime = startTime ? startTime.tz(timeZone).format("h:mm A") : "";
|
||||
const formattedEndTime = endTime ? endTime.tz(timeZone).format("h:mm A") : "";
|
||||
const formattedTimeZone = timeZone;
|
||||
|
||||
const hostName = booking.host?.name || null;
|
||||
const hostEmail = null; // Email not stored for spam decoy bookings
|
||||
const attendeeName = booking.booker?.name || null;
|
||||
const attendeeEmail = booking.booker?.email || null;
|
||||
|
||||
return (
|
||||
<DecoyBookingSuccessCard
|
||||
title={booking.title || "Booking"}
|
||||
formattedDate={formattedDate}
|
||||
formattedTime={formattedTime}
|
||||
endTime={formattedEndTime}
|
||||
formattedTimeZone={formattedTimeZone}
|
||||
hostName={hostName}
|
||||
hostEmail={hostEmail}
|
||||
attendeeName={attendeeName}
|
||||
attendeeEmail={attendeeEmail}
|
||||
location={booking.location || null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { getBookingForReschedule, type GetBookingType } from "@calcom/features/b
|
||||
import { getOrgFullOrigin, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
|
||||
import type { TeamData } from "@calcom/features/ee/teams/lib/getTeamData";
|
||||
import { shouldHideBrandingForTeamEvent } from "@calcom/lib/hideBranding";
|
||||
import { shouldHideBrandingForTeamEvent } from "@calcom/features/profile/lib/hideBranding";
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { BookingStatus, RedirectType } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -13,8 +13,8 @@ import { getTeamEventType } from "@calcom/features/eventtypes/lib/getTeamEventTy
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import type { SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -6,9 +6,10 @@ import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
|
||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
import { getScheduleListItemData } from "@calcom/lib/schedules/transformers/getScheduleListItemData";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { availabilityRouter } from "@calcom/trpc/server/routers/viewer/availability/_router";
|
||||
|
||||
@@ -56,15 +57,7 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
|
||||
// This is because the data is cached and as a result the data is converted to a string
|
||||
const availabilities = {
|
||||
...cachedAvailabilities,
|
||||
schedules: cachedAvailabilities.schedules.map((schedule) => ({
|
||||
...schedule,
|
||||
availability: schedule.availability.map((avail) => ({
|
||||
...avail,
|
||||
startTime: new Date(avail.startTime),
|
||||
endTime: new Date(avail.endTime),
|
||||
date: avail.date ? new Date(avail.date) : null,
|
||||
})),
|
||||
})),
|
||||
schedules: cachedAvailabilities.schedules.map((schedule) => getScheduleListItemData(schedule)),
|
||||
};
|
||||
|
||||
const organizationId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
|
||||
|
||||
@@ -4,8 +4,8 @@ import { unstable_cache } from "next/cache";
|
||||
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import { TeamService } from "@calcom/lib/server/service/teamService";
|
||||
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
|
||||
import { TeamService } from "@calcom/features/ee/teams/services/teamService";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { AppCategories } from "@calcom/prisma/enums";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { z } from "zod";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { OrgForm } from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgEditPage";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
|
||||
|
||||
const orgIdSchema = z.object({ id: z.coerce.number() });
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { z } from "zod";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { UsersEditView } from "@calcom/features/ee/users/pages/users-edit-view";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const userIdSchema = z.object({ id: z.coerce.number() });
|
||||
|
||||
+35
-15
@@ -98,7 +98,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "privacy",
|
||||
name: "privacy_and_security",
|
||||
href: "/settings/organizations/privacy",
|
||||
},
|
||||
|
||||
@@ -170,7 +170,14 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
// The following keys are assigned to admin only
|
||||
const adminRequiredKeys = ["admin"];
|
||||
const organizationRequiredKeys = ["organization"];
|
||||
const organizationAdminKeys = ["privacy", "OAuth Clients", "SSO", "directory_sync", "delegation_credential"];
|
||||
const organizationAdminKeys = [
|
||||
"privacy",
|
||||
"privacy_and_security",
|
||||
"OAuth Clients",
|
||||
"SSO",
|
||||
"directory_sync",
|
||||
"delegation_credential",
|
||||
];
|
||||
|
||||
export interface SettingsPermissions {
|
||||
canViewRoles?: boolean;
|
||||
@@ -221,8 +228,7 @@ const useTabs = ({
|
||||
});
|
||||
}
|
||||
|
||||
// Add pbac menu item only if feature flag is enabled AND user has permission to view roles
|
||||
// This prevents showing the menu item when user has no organization permissions
|
||||
// Add pbac menu item - show opt-in page if not enabled, regular page if enabled
|
||||
if (isPbacEnabled) {
|
||||
if (permissions?.canViewRoles) {
|
||||
newArray.push({
|
||||
@@ -244,6 +250,14 @@ const useTabs = ({
|
||||
href: "/settings/organizations/billing",
|
||||
});
|
||||
}
|
||||
// Show roles page (modal will appear if PBAC not enabled)
|
||||
if (permissions?.canUpdateOrganization) {
|
||||
newArray.push({
|
||||
name: "roles",
|
||||
href: "/settings/organizations/roles",
|
||||
isBadged: true, // Show "New" badge
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -604,17 +618,23 @@ const SettingsSidebarContainer = ({
|
||||
</div>
|
||||
<div className="my-3 space-y-px">
|
||||
{tab.children?.map((child, index) => (
|
||||
<VerticalTabItem
|
||||
key={child.href}
|
||||
name={t(child.name)}
|
||||
isExternalLink={child.isExternalLink}
|
||||
href={child.href || "/"}
|
||||
textClassNames="text-emphasis font-medium text-sm"
|
||||
className={`me-5 h-7 w-auto !px-2 ${
|
||||
tab.children && index === tab.children?.length - 1 && "!mb-3"
|
||||
}`}
|
||||
disableChevron
|
||||
/>
|
||||
<div key={child.href} className="flex items-start gap-2">
|
||||
<VerticalTabItem
|
||||
name={t(child.name)}
|
||||
isExternalLink={child.isExternalLink}
|
||||
href={child.href || "/"}
|
||||
textClassNames="text-emphasis font-medium text-sm"
|
||||
className={`h-7 w-fit !px-2 ${
|
||||
tab.children && index === tab.children?.length - 1 && "!mb-3"
|
||||
}`}
|
||||
disableChevron
|
||||
/>
|
||||
{child.isBadged && (
|
||||
<Badge variant="blue" className="mt-0.5 text-xs">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
|
||||
+3
-2
@@ -4,7 +4,7 @@ import { getTranslate, _generateMetadata } from "app/_utils";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { EditWebhookView } from "@calcom/features/webhooks/pages/webhook-edit-view";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
|
||||
import { WebhookRepository } from "@calcom/features/webhooks/lib/repository/WebhookRepository";
|
||||
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
|
||||
await _generateMetadata(
|
||||
@@ -20,7 +20,8 @@ const Page = async ({ params: _params }: PageProps) => {
|
||||
const params = await _params;
|
||||
const id = typeof params?.id === "string" ? params.id : undefined;
|
||||
|
||||
const webhook = await WebhookRepository.findByWebhookId(id);
|
||||
const webhookRepository = WebhookRepository.getInstance();
|
||||
const webhook = await webhookRepository.findByWebhookId(id);
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
|
||||
@@ -53,16 +53,23 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
|
||||
if (orgId) {
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(session.user.org?.role);
|
||||
const [features, rolePermissions, organizationPermissions] = await Promise.all([
|
||||
getTeamFeatures(orgId),
|
||||
getCachedResourcePermissions(userId, orgId, Resource.Role),
|
||||
getCachedResourcePermissions(userId, orgId, Resource.Organization),
|
||||
]);
|
||||
const features = await getTeamFeatures(orgId);
|
||||
|
||||
if (features) {
|
||||
teamFeatures = {
|
||||
[orgId]: features,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if PBAC feature is enabled
|
||||
const isPbacEnabled = features?.pbac === true;
|
||||
|
||||
if (isPbacEnabled) {
|
||||
// Only fetch and apply PBAC permissions if the feature is enabled
|
||||
const [rolePermissions, organizationPermissions] = await Promise.all([
|
||||
getCachedResourcePermissions(userId, orgId, Resource.Role),
|
||||
getCachedResourcePermissions(userId, orgId, Resource.Organization),
|
||||
]);
|
||||
|
||||
// Check if user has permission to read roles
|
||||
const roleActions = PermissionMapper.toActionMap(rolePermissions, Resource.Role);
|
||||
@@ -71,7 +78,8 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
canViewOrganizationBilling = orgActions[CustomAction.ManageBilling] ?? isOrgAdminOrOwner;
|
||||
canUpdateOrganization = orgActions[CrudAction.Update] ?? isOrgAdminOrOwner;
|
||||
} else {
|
||||
canViewRoles = false;
|
||||
// Fall back to legacy permissions when PBAC is not enabled or features not loaded
|
||||
canViewRoles = features ? isOrgAdminOrOwner : false;
|
||||
canViewOrganizationBilling = isOrgAdminOrOwner;
|
||||
canUpdateOrganization = isOrgAdminOrOwner;
|
||||
}
|
||||
|
||||
+22
-5
@@ -11,7 +11,7 @@ import { validateUserHasOrg } from "../../actions/validateUserHasOrg";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("privacy"),
|
||||
(t) => t("privacy_and_security"),
|
||||
(t) => t("privacy_organization_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -19,9 +19,8 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
|
||||
const session = await validateUserHasOrg();
|
||||
const t = await getTranslate();
|
||||
|
||||
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
|
||||
return redirect("/settings/profile");
|
||||
@@ -42,13 +41,31 @@ const Page = async () => {
|
||||
},
|
||||
});
|
||||
|
||||
const watchlistPermissions = await getResourcePermissions({
|
||||
userId: session.user.id,
|
||||
teamId: session.user.profile.organizationId,
|
||||
resource: Resource.Watchlist,
|
||||
userRole: session.user.org.role,
|
||||
fallbackRoles: {
|
||||
read: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
create: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
delete: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!canRead) {
|
||||
return redirect("/settings/profile");
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsHeader title={t("privacy")} description={t("privacy_organization_description")}>
|
||||
<PrivacyView permissions={{ canRead, canEdit }} />
|
||||
<SettingsHeader title={t("privacy_and_security")} description={t("privacy_organization_description")}>
|
||||
<PrivacyView permissions={{ canRead, canEdit }} watchlistPermissions={watchlistPermissions} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
+63
File diff suppressed because one or more lines are too long
+129
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { FingerprintAnimation } from "./FingerprintAnimation";
|
||||
|
||||
interface PbacOptInModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
revalidateRolesPath: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function PbacOptInModal({ open, onOpenChange, revalidateRolesPath }: PbacOptInModalProps) {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const [isButtonHovered, setIsButtonHovered] = useState(false);
|
||||
|
||||
const enablePbacMutation = trpc.viewer.pbac.enablePbac.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast(t("pbac_enabled_success"), "success");
|
||||
await revalidateRolesPath();
|
||||
onOpenChange(false);
|
||||
router.refresh();
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message || t("pbac_enabled_error"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
const handleOptIn = () => {
|
||||
enablePbacMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
enableOverflow
|
||||
type={undefined}
|
||||
className="max-w-[468px] p-6"
|
||||
preventCloseOnOutsideClick={true}>
|
||||
<div className="flex flex-col items-center gap-8">
|
||||
<div className="flex w-full flex-col items-center gap-6">
|
||||
<FingerprintAnimation isHovered={isButtonHovered} />
|
||||
|
||||
<div className="flex w-full flex-col items-center gap-1 text-center">
|
||||
<h2 className="font-cal text-emphasis text-xl font-semibold">{t("pbac_opt_in_title")}</h2>
|
||||
<p className="text-default text-sm">{t("pbac_opt_in_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-subtle w-full space-y-px overflow-hidden rounded-xl border border-gray-200">
|
||||
<div className="bg-default flex gap-3 p-3">
|
||||
<div className="bg-subtle flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
|
||||
<Icon name="shield-check" className="text-subtle h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-emphasis text-sm font-semibold">{t("pbac_opt_in_custom_roles_title")}</p>
|
||||
<p className="text-subtle text-sm">{t("pbac_opt_in_custom_roles_desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-default flex gap-3 p-3">
|
||||
<div className="bg-subtle flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
|
||||
<Icon name="lock" className="text-subtle h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-emphasis text-sm font-semibold">
|
||||
{t("pbac_opt_in_granular_permissions_title")}
|
||||
</p>
|
||||
<p className="text-subtle text-sm">{t("pbac_opt_in_granular_permissions_desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-default flex gap-3 p-3">
|
||||
<div className="bg-subtle flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
|
||||
<Icon name="users" className="text-subtle h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-emphasis text-sm font-semibold">
|
||||
{t("pbac_opt_in_team_assignment_title")}
|
||||
</p>
|
||||
<p className="text-subtle text-sm">{t("pbac_opt_in_team_assignment_desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-default flex gap-3 p-3">
|
||||
<div className="bg-subtle flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
|
||||
<Icon name="shield" className="text-subtle h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-emphasis text-sm font-semibold">
|
||||
{t("pbac_opt_in_enhanced_security_title")}
|
||||
</p>
|
||||
<p className="text-subtle text-sm">{t("pbac_opt_in_enhanced_security_desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row">
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={enablePbacMutation.isPending}
|
||||
className="w-full justify-center sm:w-1/4">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleOptIn}
|
||||
loading={enablePbacMutation.isPending}
|
||||
className="w-full justify-center sm:w-3/4"
|
||||
EndIcon="arrow-right"
|
||||
onMouseEnter={() => setIsButtonHovered(true)}
|
||||
onMouseLeave={() => setIsButtonHovered(false)}>
|
||||
{t("pbac_opt_in_enable_button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import { PbacOptInModal } from "./PbacOptInModal";
|
||||
import { RolesList } from "./RolesList";
|
||||
|
||||
type Role = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
teamId?: number;
|
||||
color?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
type: "SYSTEM" | "CUSTOM";
|
||||
permissions: {
|
||||
id: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
interface PbacOptInViewProps {
|
||||
revalidateRolesPath: () => Promise<void>;
|
||||
systemRoles: Role[];
|
||||
teamId: number;
|
||||
}
|
||||
|
||||
export function PbacOptInView({ revalidateRolesPath, systemRoles, teamId }: PbacOptInViewProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsHeader
|
||||
title={t("roles_and_permissions")}
|
||||
description={t("roles_and_permissions_description")}
|
||||
borderInShellHeader={false}
|
||||
CTA={null}>
|
||||
<RolesList
|
||||
teamId={teamId}
|
||||
roles={systemRoles}
|
||||
permissions={{
|
||||
canCreate: false,
|
||||
canRead: true,
|
||||
canUpdate: false,
|
||||
canDelete: false,
|
||||
}}
|
||||
isPrivate={false}
|
||||
/>
|
||||
</SettingsHeader>
|
||||
<PbacOptInModal open={open} onOpenChange={setOpen} revalidateRolesPath={revalidateRolesPath} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+19
-1
@@ -1,6 +1,7 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { notFound } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import type { AppFlags } from "@calcom/features/flags/config";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
@@ -13,6 +14,7 @@ import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { validateUserHasOrg } from "../actions/validateUserHasOrg";
|
||||
import { CreateRoleCTA } from "./_components/CreateRoleCta";
|
||||
import { PbacOptInView } from "./_components/PbacOptInView";
|
||||
import { RolesList } from "./_components/RolesList";
|
||||
import { roleSearchParamsCache } from "./_components/searchParams";
|
||||
|
||||
@@ -65,6 +67,11 @@ export const generateMetadata = async () =>
|
||||
"/settings/organizations/roles"
|
||||
);
|
||||
|
||||
async function revalidateRolesPath() {
|
||||
"use server";
|
||||
revalidatePath("/settings/organizations/roles");
|
||||
}
|
||||
|
||||
const Page = async ({ searchParams }: { searchParams: Record<string, string | string[] | undefined> }) => {
|
||||
const t = await getTranslate();
|
||||
const session = await validateUserHasOrg();
|
||||
@@ -76,7 +83,18 @@ const Page = async ({ searchParams }: { searchParams: Record<string, string | st
|
||||
const teamHasPBACFeature = await getCachedTeamFeature(session.user.org.id, "pbac");
|
||||
|
||||
if (!teamHasPBACFeature) {
|
||||
return notFound();
|
||||
// Get system roles for preview
|
||||
const roleService = new RoleService();
|
||||
const systemRoles = (await roleService.getTeamRoles(session.user.org.id)).filter(
|
||||
(role) => role.type === "SYSTEM"
|
||||
);
|
||||
return (
|
||||
<PbacOptInView
|
||||
revalidateRolesPath={revalidateRolesPath}
|
||||
systemRoles={systemRoles}
|
||||
teamId={session.user.org.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
roleSearchParamsCache.parse(searchParams);
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { redirect } from "next/navigation";
|
||||
|
||||
import { OtherTeamsListing } from "@calcom/features/ee/organizations/pages/components/OtherTeamsListing";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
|
||||
|
||||
import { validateUserHasOrg } from "../../../actions/validateUserHasOrg";
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import ResumeOnboardingPage, { LayoutWrapper } from "~/settings/organizations/new/resume-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("resume_onboarding"),
|
||||
(t) => t("resume_onboarding_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/organizations/new/resume"
|
||||
);
|
||||
|
||||
const ServerPage = async () => {
|
||||
return (
|
||||
<LayoutWrapper>
|
||||
<ResumeOnboardingPage />
|
||||
</LayoutWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -5,7 +5,7 @@ import { cookies, headers } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
// import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
// import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
|
||||
// import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
|
||||
// import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository";
|
||||
import LegacyPage from "@calcom/features/ee/workflows/pages/index";
|
||||
|
||||
const Page = async () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { getCalendarCredentials, getConnectedCalendars } from "@calcom/features/
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import notEmpty from "@calcom/lib/notEmpty";
|
||||
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CalendarCacheEventService } from "@calcom/features/calendar-subscriptio
|
||||
import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository";
|
||||
import { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
import { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
|
||||
import { DelegationCredentialRepository } from "@calcom/lib/server/repository/delegationCredential";
|
||||
|
||||
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { findUniqueDelegationCalendarCredential } from "@calcom/app-store/delega
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
|
||||
import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential";
|
||||
import type { Ensure } from "@calcom/types/utils";
|
||||
|
||||
@@ -9,8 +9,8 @@ import { intercom } from "@calcom/features/ee/support/lib/intercom/intercom";
|
||||
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -5,6 +5,7 @@ import type Stripe from "stripe";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
|
||||
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
|
||||
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -57,6 +58,9 @@ async function handler(request: NextRequest) {
|
||||
});
|
||||
|
||||
if (checkoutSessionSubscription) {
|
||||
const { subscriptionStart } =
|
||||
StripeBillingService.extractSubscriptionDates(checkoutSessionSubscription);
|
||||
|
||||
const internalBillingService = new InternalTeamBilling(finalizedTeam);
|
||||
await internalBillingService.saveTeamBilling({
|
||||
teamId: finalizedTeam.id,
|
||||
@@ -66,6 +70,7 @@ async function handler(request: NextRequest) {
|
||||
// TODO: Implement true subscription status when webhook events are implemented
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
planName: Plan.TEAM,
|
||||
subscriptionStart,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type Stripe from "stripe";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository";
|
||||
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
|
||||
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -87,6 +88,7 @@ async function getHandler(req: NextRequest) {
|
||||
});
|
||||
|
||||
if (checkoutSession && subscription) {
|
||||
const { subscriptionStart } = StripeBillingService.extractSubscriptionDates(subscription);
|
||||
const internalBillingService = new InternalTeamBilling(team);
|
||||
await internalBillingService.saveTeamBilling({
|
||||
teamId: team.id,
|
||||
@@ -96,6 +98,7 @@ async function getHandler(req: NextRequest) {
|
||||
// TODO: Implement true subscription status when webhook events are implemented
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
planName: Plan.TEAM,
|
||||
subscriptionStart,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { checkUsername } from "@calcom/lib/server/checkUsername";
|
||||
import { checkUsername } from "@calcom/features/profile/lib/checkUsername";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository";
|
||||
import { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -3,7 +3,7 @@
|
||||
import { revalidateTag, unstable_cache } from "next/cache";
|
||||
|
||||
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
|
||||
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
|
||||
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
||||
|
||||
const CACHE_TAGS = {
|
||||
HAS_TEAM_PLAN: "MembershipRepository.findFirstAcceptedMembershipByUserId",
|
||||
|
||||
@@ -160,13 +160,21 @@ export const AppPage = ({
|
||||
function refactorMeWithoutEffect() {
|
||||
const data = appDbQuery.data;
|
||||
|
||||
const credentialsCount = data?.credentials.length || 0;
|
||||
setExistingCredentials(data?.credentials || []);
|
||||
const credentials = data?.credentials || [];
|
||||
setExistingCredentials(credentials);
|
||||
|
||||
const hasPersonalInstall = credentials.some((c) => !!c.userId && !c.teamId);
|
||||
const installedTeamIds = new Set<number>();
|
||||
for (const cred of credentials) {
|
||||
if (cred.teamId) installedTeamIds.add(cred.teamId);
|
||||
}
|
||||
|
||||
const totalInstalledTargets = (hasPersonalInstall ? 1 : 0) + installedTeamIds.size;
|
||||
|
||||
const appInstalledForAllTargets =
|
||||
availableForTeams && data?.userAdminTeams && data.userAdminTeams.length > 0
|
||||
? credentialsCount >= data.userAdminTeams.length
|
||||
: credentialsCount > 0;
|
||||
? totalInstalledTargets >= data.userAdminTeams.length + 1
|
||||
: credentials.length > 0;
|
||||
setAppInstalledForAllTargets(appInstalledForAllTargets);
|
||||
},
|
||||
[appDbQuery.data, availableForTeams]
|
||||
@@ -244,7 +252,14 @@ export const AppPage = ({
|
||||
loading: isLoading,
|
||||
};
|
||||
}
|
||||
return <InstallAppButtonChild credentials={appDbQuery.data?.credentials} paid={paid} {...props} />;
|
||||
|
||||
return (
|
||||
<InstallAppButtonChild
|
||||
credentials={availableForTeams ? undefined : appDbQuery.data?.credentials}
|
||||
paid={paid}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useEffect, useState } from "react";
|
||||
import QRCode from "react-qr-code";
|
||||
import z from "zod";
|
||||
|
||||
import { useBookingSuccessRedirect } from "@calcom/features/bookings/lib/bookingSuccessRedirect";
|
||||
import type { PaymentPageProps } from "@calcom/features/ee/payments/pages/payment";
|
||||
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import z from "zod";
|
||||
|
||||
import type { PaymentPageProps } from "@calcom/features/ee/payments/pages/payment";
|
||||
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
|
||||
import { useBookingSuccessRedirect } from "@calcom/features/bookings/lib/bookingSuccessRedirect";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
@@ -52,6 +52,7 @@ import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog";
|
||||
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import { ReassignDialog } from "@components/dialog/ReassignDialog";
|
||||
import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
|
||||
import { RerouteDialog } from "@components/dialog/RerouteDialog";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
|
||||
@@ -60,9 +61,11 @@ import {
|
||||
getCancelEventAction,
|
||||
getEditEventActions,
|
||||
getAfterEventActions,
|
||||
getReportAction,
|
||||
shouldShowPendingActions,
|
||||
shouldShowEditActions,
|
||||
shouldShowRecurringCancelAction,
|
||||
shouldShowIndividualReportButton,
|
||||
type BookingActionContext,
|
||||
} from "./bookingActions";
|
||||
|
||||
@@ -181,6 +184,13 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const isPending = booking.status === BookingStatus.PENDING;
|
||||
const isRescheduled = booking.fromReschedule !== null;
|
||||
const isRecurring = booking.recurringEventId !== null;
|
||||
|
||||
const getBookingStatus = (): "upcoming" | "past" | "cancelled" | "rejected" => {
|
||||
if (isCancelled) return "cancelled";
|
||||
if (isRejected) return "rejected";
|
||||
if (isBookingInPast) return "past";
|
||||
return "upcoming";
|
||||
};
|
||||
const isTabRecurring = booking.listingStatus === "recurring";
|
||||
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
||||
const isBookingFromRoutingForm = isBookingReroutable(parsedBooking);
|
||||
@@ -292,6 +302,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const [isOpenReassignDialog, setIsOpenReassignDialog] = useState(false);
|
||||
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
|
||||
const [isOpenAddGuestsDialog, setIsOpenAddGuestsDialog] = useState(false);
|
||||
const [isOpenReportDialog, setIsOpenReportDialog] = useState(false);
|
||||
const [rerouteDialogIsOpen, setRerouteDialogIsOpen] = useState(false);
|
||||
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
|
||||
onSuccess: () => {
|
||||
@@ -402,6 +413,12 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
(action.id === "view_recordings" && !booking.isRecorded),
|
||||
})) as ActionType[];
|
||||
|
||||
const reportAction = getReportAction(actionContext);
|
||||
const reportActionWithHandler = {
|
||||
...reportAction,
|
||||
onClick: () => setIsOpenReportDialog(true),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
@@ -430,6 +447,13 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
setIsOpenDialog={setIsOpenAddGuestsDialog}
|
||||
bookingId={booking.id}
|
||||
/>
|
||||
<ReportBookingDialog
|
||||
isOpenDialog={isOpenReportDialog}
|
||||
setIsOpenDialog={setIsOpenReportDialog}
|
||||
bookingUid={booking.uid}
|
||||
isRecurring={isRecurring}
|
||||
status={getBookingStatus()}
|
||||
/>
|
||||
{booking.paid && booking.payment[0] && (
|
||||
<ChargeCardDialog
|
||||
isOpenDialog={chargeCardDialogIsOpen}
|
||||
@@ -685,6 +709,24 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
key={reportActionWithHandler.id}
|
||||
disabled={reportActionWithHandler.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={reportActionWithHandler.color}
|
||||
StartIcon={reportActionWithHandler.icon}
|
||||
onClick={reportActionWithHandler.onClick}
|
||||
disabled={reportActionWithHandler.disabled}
|
||||
data-testid={reportActionWithHandler.id}
|
||||
className={reportActionWithHandler.disabled ? "text-muted" : undefined}>
|
||||
{reportActionWithHandler.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
@@ -708,6 +750,21 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</Dropdown>
|
||||
)}
|
||||
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
|
||||
{shouldShowIndividualReportButton(actionContext) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
color="destructive"
|
||||
StartIcon={reportActionWithHandler.icon}
|
||||
onClick={reportActionWithHandler.onClick}
|
||||
disabled={reportActionWithHandler.disabled}
|
||||
data-testid={reportActionWithHandler.id}
|
||||
className="h-8 w-8"
|
||||
tooltip={reportActionWithHandler.label}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="hidden h-full items-center md:flex">
|
||||
@@ -776,6 +833,24 @@ const BookingItemBadges = ({
|
||||
{booking?.assignmentReason.length > 0 && (
|
||||
<AssignmentReasonTooltip assignmentReason={booking.assignmentReason[0]} />
|
||||
)}
|
||||
{booking.report && (
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="text-xs">
|
||||
{(() => {
|
||||
const reasonKey = `report_reason_${booking.report.reason.toLowerCase()}`;
|
||||
const reasonText = t(reasonKey);
|
||||
return booking.report.description
|
||||
? `${reasonText}: ${booking.report.description}`
|
||||
: reasonText;
|
||||
})()}
|
||||
</div>
|
||||
}>
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="red">
|
||||
{t("reported")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{booking.paid && !booking.payment[0] ? (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
|
||||
{t("error_collecting_card")}
|
||||
|
||||
@@ -64,13 +64,14 @@ export function getPendingActions(context: BookingActionContext): ActionType[] {
|
||||
|
||||
export function getCancelEventAction(context: BookingActionContext): ActionType {
|
||||
const { booking, isTabRecurring, isRecurring, getSeatReferenceUid, t } = context;
|
||||
const seatReferenceUid = getSeatReferenceUid();
|
||||
|
||||
return {
|
||||
id: "cancel",
|
||||
label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel_event"),
|
||||
href: `/booking/${booking.uid}?cancel=true${
|
||||
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
|
||||
}${booking.seatsReferences.length ? `&seatReferenceUid=${getSeatReferenceUid()}` : ""}`,
|
||||
}${booking.seatsReferences.length && seatReferenceUid ? `&seatReferenceUid=${seatReferenceUid}` : ""}`,
|
||||
icon: "circle-x",
|
||||
color: "destructive",
|
||||
disabled: isActionDisabled("cancel", context),
|
||||
@@ -106,6 +107,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
|
||||
isAttendee,
|
||||
t,
|
||||
} = context;
|
||||
const seatReferenceUid = getSeatReferenceUid();
|
||||
|
||||
const actions: (ActionType | null)[] = [
|
||||
{
|
||||
@@ -113,7 +115,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
|
||||
icon: "clock",
|
||||
label: t("reschedule_booking"),
|
||||
href: `/reschedule/${booking.uid}${
|
||||
booking.seatsReferences.length && isAttendee ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
|
||||
booking.seatsReferences.length && isAttendee && seatReferenceUid ? `?seatReferenceUid=${seatReferenceUid}` : ""
|
||||
}`,
|
||||
disabled:
|
||||
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling,
|
||||
@@ -163,6 +165,18 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
|
||||
return actions.filter(Boolean) as ActionType[];
|
||||
}
|
||||
|
||||
export function getReportAction(context: BookingActionContext): ActionType {
|
||||
const { booking, t } = context;
|
||||
|
||||
return {
|
||||
id: "report",
|
||||
label: t("report_booking"),
|
||||
icon: "flag",
|
||||
color: "destructive",
|
||||
disabled: !!booking.report,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAfterEventActions(context: BookingActionContext): ActionType[] {
|
||||
const { booking, cardCharged, attendeeList, t } = context;
|
||||
|
||||
@@ -203,9 +217,14 @@ export function shouldShowRecurringCancelAction(context: BookingActionContext):
|
||||
return isTabRecurring && isRecurring;
|
||||
}
|
||||
|
||||
export function shouldShowIndividualReportButton(context: BookingActionContext): boolean {
|
||||
const { booking, isPending, isUpcoming, isCancelled, isRejected } = context;
|
||||
const hasDropdown = shouldShowEditActions(context);
|
||||
return !booking.report && !hasDropdown && (isCancelled || isRejected || (isPending && isUpcoming));
|
||||
}
|
||||
|
||||
export function isActionDisabled(actionId: string, context: BookingActionContext): boolean {
|
||||
const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling, isPending, isConfirmed } =
|
||||
context;
|
||||
const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling } = context;
|
||||
|
||||
switch (actionId) {
|
||||
case "reschedule":
|
||||
@@ -225,7 +244,7 @@ export function isActionDisabled(actionId: string, context: BookingActionContext
|
||||
}
|
||||
|
||||
export function getActionLabel(actionId: string, context: BookingActionContext): string {
|
||||
const { booking, isTabRecurring, isRecurring, attendeeList, cardCharged, t } = context;
|
||||
const { isTabRecurring, isRecurring, attendeeList, cardCharged, t } = context;
|
||||
|
||||
switch (actionId) {
|
||||
case "reject":
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { BookingReportReason } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog";
|
||||
import { Select, Label } from "@calcom/ui/components/form";
|
||||
import { TextArea } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
type BookingReportStatus = "upcoming" | "past" | "cancelled" | "rejected";
|
||||
|
||||
interface IReportBookingDialog {
|
||||
isOpenDialog: boolean;
|
||||
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
|
||||
bookingUid: string;
|
||||
isRecurring: boolean;
|
||||
status: BookingReportStatus;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
reason: BookingReportReason;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const ReportBookingDialog = (props: IReportBookingDialog) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const { isOpenDialog, setIsOpenDialog, bookingUid, status } = props;
|
||||
|
||||
const willBeCancelled = status === "upcoming";
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
reason: BookingReportReason.SPAM,
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: reportBooking, isPending } = trpc.viewer.bookings.reportBooking.useMutation({
|
||||
async onSuccess(data) {
|
||||
showToast(data.message, "success");
|
||||
setIsOpenDialog(false);
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError(error) {
|
||||
showToast(error.message || t("unexpected_error_try_again"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
reportBooking({
|
||||
bookingUid,
|
||||
reason: data.reason,
|
||||
description: data.description || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const reasonOptions = [
|
||||
{ label: t("report_reason_spam"), value: BookingReportReason.SPAM },
|
||||
{ label: t("report_reason_dont_know_person"), value: BookingReportReason.DONT_KNOW_PERSON },
|
||||
{ label: t("report_reason_other"), value: BookingReportReason.OTHER },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent enableOverflow>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="w-full">
|
||||
<DialogHeader title={t("report_booking")} subtitle={t("report_booking_description")} />
|
||||
<div className="mb-4">
|
||||
<Label htmlFor="reason" className="text-emphasis mb-2 block text-sm font-medium">
|
||||
{t("reason")} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Controller
|
||||
name="reason"
|
||||
control={control}
|
||||
rules={{ required: t("field_required") }}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
options={reasonOptions}
|
||||
onChange={(option) => {
|
||||
if (option) field.onChange(option.value);
|
||||
}}
|
||||
value={reasonOptions.find((opt) => opt.value === field.value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.reason && <p className="text-destructive mt-1 text-sm">{errors.reason.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<Label htmlFor="description" className="text-emphasis mb-2 block text-sm font-medium">
|
||||
{t("description")} <span className="text-subtle font-normal">({t("optional")})</span>
|
||||
</Label>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextArea {...field} placeholder={t("report_booking_description_placeholder")} rows={3} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{willBeCancelled && (
|
||||
<div className="mb-4">
|
||||
<Alert severity="warning" title={t("report_booking_will_cancel_description")} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter showDivider className="mt-8">
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
onClick={() => setIsOpenDialog(false)}
|
||||
disabled={isPending}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" color="primary" disabled={isPending} loading={isPending}>
|
||||
{t("submit_report")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -15,10 +15,10 @@ import { substituteVariables } from "@calcom/app-store/routing-forms/lib/substit
|
||||
import type { FormResponse, LocalRoute } from "@calcom/app-store/routing-forms/types/types";
|
||||
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useBookerUrl } from "@calcom/features/bookings/hooks/useBookerUrl";
|
||||
import { createBooking } from "@calcom/features/bookings/lib/create-booking";
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { getUrlSearchParamsToForwardForReroute } from "@calcom/features/routing-forms/lib/getUrlSearchParamsToForward";
|
||||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { EventType, User, Team, Attendee, Booking as PrismaBooking } from "@calcom/prisma/client";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React from "react";
|
||||
|
||||
import "@calcom/embed-core/src/embed-iframe";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ 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 { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
|
||||
@@ -65,7 +65,7 @@ export const getServerSideProps = async function getServerSideProps(
|
||||
};
|
||||
}
|
||||
|
||||
const { UserRepository } = await import("@calcom/lib/server/repository/user");
|
||||
const { UserRepository } = await import("@calcom/features/users/repositories/UserRepository");
|
||||
const userRepo = new UserRepository(prisma);
|
||||
const formWithUserProfile = {
|
||||
...form,
|
||||
@@ -83,9 +83,12 @@ export const getServerSideProps = async function getServerSideProps(
|
||||
props: {
|
||||
isEmbed,
|
||||
profile: {
|
||||
theme: form.user.theme,
|
||||
brandColor: form.user.brandColor,
|
||||
darkBrandColor: form.user.darkBrandColor,
|
||||
theme: formWithUserProfile.user.profile?.organization?.theme ?? formWithUserProfile.user.theme,
|
||||
brandColor:
|
||||
formWithUserProfile.user.profile?.organization?.brandColor ?? formWithUserProfile.user.brandColor,
|
||||
darkBrandColor:
|
||||
formWithUserProfile.user.profile?.organization?.darkBrandColor ??
|
||||
formWithUserProfile.user.darkBrandColor,
|
||||
},
|
||||
form: await getSerializableForm({ form: enrichFormWithMigrationData(formWithUserProfile) }),
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import { enrichFormWithMigrationData } from "@calcom/app-store/routing-forms/enr
|
||||
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
|
||||
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { AppGetServerSidePropsContext, AppPrisma, AppUser } from "@calcom/types/AppGetServerSideProps";
|
||||
|
||||
@@ -90,7 +90,7 @@ export const getServerSidePropsForSingleFormView = async function getServerSideP
|
||||
: null,
|
||||
};
|
||||
|
||||
const { UserRepository } = await import("@calcom/lib/server/repository/user");
|
||||
const { UserRepository } = await import("@calcom/features/users/repositories/UserRepository");
|
||||
|
||||
const userRepo = new UserRepository(prisma);
|
||||
const formWithUserInfoProfile = {
|
||||
|
||||
+15
-3
@@ -49,6 +49,13 @@ export const getEventTypesFromDB = async (id: number) => {
|
||||
profile: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
organization: {
|
||||
select: {
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
theme: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
teamId: true,
|
||||
@@ -71,9 +78,15 @@ export const getEventTypesFromDB = async (id: number) => {
|
||||
slug: true,
|
||||
name: true,
|
||||
hideBranding: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
theme: true,
|
||||
parent: {
|
||||
select: {
|
||||
hideBranding: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
theme: true,
|
||||
},
|
||||
},
|
||||
createdByOAuthClientId: true,
|
||||
@@ -116,12 +129,11 @@ export const getEventTypesFromDB = async (id: number) => {
|
||||
}
|
||||
|
||||
const metadata = EventTypeMetaDataSchema.parse(eventType.metadata);
|
||||
const { profile, ...restEventType } = eventType;
|
||||
const isOrgTeamEvent = !!eventType?.team && !!profile?.organizationId;
|
||||
const isOrgTeamEvent = !!eventType?.team && !!eventType.profile?.organizationId;
|
||||
|
||||
return {
|
||||
isDynamic: false,
|
||||
...restEventType,
|
||||
...eventType,
|
||||
bookingFields: getBookingFieldsWithSystemFields({ ...eventType, isOrgTeamEvent }),
|
||||
metadata,
|
||||
};
|
||||
|
||||
@@ -6,11 +6,14 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { EventRepository } from "@calcom/features/eventtypes/repositories/EventRepository";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { shouldHideBrandingForTeamEvent, shouldHideBrandingForUserEvent } from "@calcom/lib/hideBranding";
|
||||
import { EventRepository } from "@calcom/lib/server/repository/event";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService";
|
||||
import { HashedLinkService } from "@calcom/features/hashedLink/services/hashedLinkService";
|
||||
import {
|
||||
shouldHideBrandingForTeamEvent,
|
||||
shouldHideBrandingForUserEvent,
|
||||
} from "@calcom/features/profile/lib/hideBranding";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { RedirectType } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -5,8 +5,8 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { shouldHideBrandingForTeamEvent } from "@calcom/lib/hideBranding";
|
||||
import { EventRepository } from "@calcom/lib/server/repository/event";
|
||||
import { shouldHideBrandingForTeamEvent } from "@calcom/features/profile/lib/hideBranding";
|
||||
import { EventRepository } from "@calcom/features/eventtypes/repositories/EventRepository";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { organizationScenarios } from "@calcom/lib/server/repository/__mocks__/organization";
|
||||
import { organizationScenarios } from "@calcom/features/ee/organizations/__mocks__/organizationMock";
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
@@ -10,14 +10,14 @@ import { moveUserToMatchingOrg } from "./verify-email";
|
||||
|
||||
// TODO: This test passes but coverage is very low.
|
||||
vi.mock("@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler");
|
||||
vi.mock("@calcom/lib/server/repository/organization");
|
||||
vi.mock("@calcom/features/ee/organizations/repositories/OrganizationRepository");
|
||||
vi.mock("@calcom/prisma", () => {
|
||||
return {
|
||||
prisma: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/features/ee/billing/stripe-billling-service", () => {
|
||||
vi.mock("@calcom/features/ee/billing/stripe-billing-service", () => {
|
||||
return {
|
||||
StripeBillingService: vi.fn(),
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user