fix: Add new route to create team routing-form response again(after revert earlier) (#22407)

* Revert "revert: "fix: Add new route to create team routing-form response (#22347)" (#22399)"

This reverts commit e6c36af3b4.

* Update the documentation

* Remove versioning
This commit is contained in:
Hariom Balhara
2025-07-17 11:33:51 +05:30
committed by GitHub
parent 4f95cff70e
commit ec58e59865
15 changed files with 5469 additions and 1463 deletions
@@ -0,0 +1 @@
export { Or } from "./or.guard";
@@ -0,0 +1,104 @@
import { ExecutionContext, CanActivate } from "@nestjs/common";
import { Or } from "./or.guard";
// Mock guards for testing
class MockGuard1 implements CanActivate {
constructor(private shouldPass: boolean) {}
async canActivate(): Promise<boolean> {
return this.shouldPass;
}
}
class MockGuard2 implements CanActivate {
constructor(private shouldPass: boolean) {}
async canActivate(): Promise<boolean> {
return this.shouldPass;
}
}
class MockGuard3 implements CanActivate {
constructor(private shouldThrow: boolean = false) {}
async canActivate(): Promise<boolean> {
if (this.shouldThrow) {
throw new Error("Guard failed");
}
return false;
}
}
describe("OrGuard", () => {
let guard: InstanceType<ReturnType<typeof Or>>;
let mockExecutionContext: ExecutionContext;
let mockModuleRef: any;
beforeEach(() => {
mockModuleRef = {
get: jest.fn(),
};
const OrGuardClass = Or([MockGuard1, MockGuard2]);
guard = new OrGuardClass(mockModuleRef);
mockExecutionContext = {} as ExecutionContext;
});
it("should be defined", () => {
expect(guard).toBeDefined();
});
it("should grant access when first guard passes", async () => {
const mockGuard1 = new MockGuard1(true);
const mockGuard2 = new MockGuard2(false);
mockModuleRef.get.mockReturnValueOnce(mockGuard1).mockReturnValueOnce(mockGuard2);
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
});
it("should grant access when second guard passes", async () => {
const mockGuard1 = new MockGuard1(false);
const mockGuard2 = new MockGuard2(true);
mockModuleRef.get.mockReturnValueOnce(mockGuard1).mockReturnValueOnce(mockGuard2);
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
});
it("should deny access when all guards fail", async () => {
const mockGuard1 = new MockGuard1(false);
const mockGuard2 = new MockGuard2(false);
mockModuleRef.get.mockReturnValueOnce(mockGuard1).mockReturnValueOnce(mockGuard2);
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(false);
});
it("should continue checking other guards when one throws an error", async () => {
const mockGuard1 = new MockGuard3(true); // throws error
const mockGuard2 = new MockGuard2(true); // passes
mockModuleRef.get.mockReturnValueOnce(mockGuard1).mockReturnValueOnce(mockGuard2);
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
});
});
describe("Or decorator", () => {
it("should create a guard class with the specified guards", () => {
const OrGuardClass = Or([MockGuard1, MockGuard2]);
expect(OrGuardClass).toBeDefined();
expect(typeof OrGuardClass).toBe("function");
});
});
@@ -0,0 +1,49 @@
import { Injectable, CanActivate, ExecutionContext, Type, Logger } from "@nestjs/common";
import { ModuleRef } from "@nestjs/core";
/**
* Decorator function that creates an Or guard with the specified guards
* @param guards Array of guard classes to evaluate with OR logic
* @returns A guard class that grants access if ANY of the provided guards return true
*/
export function Or(guards: Type<CanActivate>[]) {
@Injectable()
class OrGuard implements CanActivate {
public readonly logger = new Logger("OrGuard");
constructor(public readonly moduleRef: ModuleRef) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
let lastError: unknown | null = null;
for (const Guard of guards) {
try {
const guardInstance = this.moduleRef.get(Guard, { strict: false });
const result = await Promise.resolve(guardInstance.canActivate(context));
if (result === true) {
this.logger.log(`OrGuard - Guard ${Guard.name} granted access`);
return true; // Access granted if any guard returns true
}
} catch (error) {
lastError = error;
// If a guard throws an exception, it implies failure for that specific guard.
// We catch it and continue checking other guards in the OR chain.
// If an exception should stop the entire chain immediately, re-throw it here.
this.logger.log(
`OrGuard - Guard ${Guard.name} failed: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
}
}
this.logger.log("OrGuard - All guards failed, access denied");
if (lastError) {
throw lastError;
}
return false;
}
}
return OrGuard;
}
@@ -0,0 +1,39 @@
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
@Injectable()
export class IsUserRoutingForm implements CanActivate {
constructor(private readonly dbRead: PrismaReadService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
const routingFormId: string = request.params.routingFormId;
const user = request.user as ApiAuthGuardUser;
if (!routingFormId) {
throw new ForbiddenException("IsUserRoutingForm - No routing form id found in request params.");
}
const userRoutingForm = await this.dbRead.prisma.app_RoutingForms_Form.findFirst({
where: {
id: routingFormId,
userId: Number(user.id),
teamId: null,
},
select: {
id: true,
},
});
if (!userRoutingForm) {
throw new ForbiddenException(
`Routing Form with id=${routingFormId} is not a user Routing Form owned by user with id=${user.id}.`
);
}
return true;
}
}
@@ -23,27 +23,48 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
let app: INestApplication;
let prismaWriteService: PrismaWriteService;
let org: Team;
let team: Team;
let apiKeyString: string;
let routingForm: App_RoutingForms_Form;
let routingFormResponse: App_RoutingForms_FormResponse;
let routingFormResponse2: App_RoutingForms_FormResponse;
// Grouped data structures
let orgAdminData: {
user: User;
apiKey: string;
eventType: {
id: number;
slug: string | null;
teamId: number | null;
userId: number | null;
title: string;
};
routingForm: App_RoutingForms_Form;
};
let teamData: {
team: Team;
routingForm: App_RoutingForms_Form;
routingFormResponse1: App_RoutingForms_FormResponse;
routingFormResponse2: App_RoutingForms_FormResponse;
};
let nonOrgAdminData: {
user: User;
apiKey: string;
eventType: {
id: number;
slug: string | null;
teamId: number | null;
userId: number | null;
title: string;
};
routingForm: App_RoutingForms_Form;
};
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let user: User;
const userEmail = `OrganizationsRoutingFormsResponsesController-key-bookings-2024-08-13-user-${randomString()}@api.com`;
let profileRepositoryFixture: ProfileRepositoryFixture;
let routingEventType: {
id: number;
slug: string | null;
teamId: number | null;
userId: number | null;
title: string;
};
let membershipsRepositoryFixture: MembershipRepositoryFixture;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
@@ -64,25 +85,34 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
isOrganization: true,
});
team = await teamRepositoryFixture.create({
// Initialize grouped data structures
orgAdminData = {} as any;
teamData = {} as any;
nonOrgAdminData = {} as any;
teamData.team = await teamRepositoryFixture.create({
name: "OrganizationsRoutingFormsResponsesController orgs booking 1",
isOrganization: false,
parent: { connect: { id: org.id } },
});
user = await userRepositoryFixture.create({
orgAdminData.user = await userRepositoryFixture.create({
email: userEmail,
});
nonOrgAdminData.user = await userRepositoryFixture.create({
email: `non-org-admin-user-${randomString()}@api.com`,
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: user.id } },
user: { connect: { id: orgAdminData.user.id } },
team: { connect: { id: org.id } },
accepted: true,
});
await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
uid: `usr-${orgAdminData.user.id}`,
username: userEmail,
organization: {
connect: {
@@ -91,98 +121,166 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
},
user: {
connect: {
id: user.id,
id: orgAdminData.user.id,
},
},
});
const now = new Date();
now.setDate(now.getDate() + 1);
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = `${keyString}`;
const { keyString } = await apiKeysRepositoryFixture.createApiKey(orgAdminData.user.id, null);
orgAdminData.apiKey = `${keyString}`;
const { keyString: _nonOrgAdminUserApiKey } = await apiKeysRepositoryFixture.createApiKey(
nonOrgAdminData.user.id,
null
);
nonOrgAdminData.apiKey = `${_nonOrgAdminUserApiKey}`;
// Create an event type for routing form to route to
routingEventType = await prismaWriteService.prisma.eventType.create({
orgAdminData.eventType = await prismaWriteService.prisma.eventType.create({
data: {
title: "Test Event Type",
slug: "test-event-type",
length: 30,
userId: user.id,
teamId: team.id,
userId: orgAdminData.user.id,
teamId: null,
},
});
routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
nonOrgAdminData.eventType = await prismaWriteService.prisma.eventType.create({
data: {
name: "Test Routing Form",
description: "Test Description",
disabled: false,
routes: [
{
title: "Test Event Type",
slug: "test-event-type",
length: 30,
userId: nonOrgAdminData.user.id,
teamId: null,
},
});
const routingFormData = {
name: "Test Routing Form",
description: "Test Description",
disabled: false,
routes: [
{
id: "route-1",
queryValue: {
id: "route-1",
queryValue: {
id: "route-1",
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "question1",
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"],
},
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "question1",
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
action: null,
isFallback: false,
},
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" },
},
],
fields: [
{
id: "question1",
type: "text",
label: "Question 1",
required: true,
identifier: "question1",
},
{
id: "question2",
type: "text",
label: "Question 2",
required: false,
identifier: "question2",
},
],
settings: {
emailOwnerOnSubmission: false,
},
};
orgAdminData.routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
...routingFormData,
routes: [
{
...routingFormData.routes[0],
action: {
type: "eventTypeRedirectUrl",
eventTypeId: routingEventType.id,
value: `team/${team.slug}/${routingEventType.slug}`,
eventTypeId: orgAdminData.eventType.id,
value: `${orgAdminData.user.username}/${orgAdminData.eventType.slug}`,
},
isFallback: false,
},
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" },
},
routingFormData.routes[1],
],
fields: [
{
id: "question1",
type: "text",
label: "Question 1",
required: true,
identifier: "question1",
},
{
id: "question2",
type: "text",
label: "Question 2",
required: false,
identifier: "question2",
},
],
settings: {
emailOwnerOnSubmission: false,
},
teamId: team.id,
userId: user.id,
userId: orgAdminData.user.id,
// User Routing Form has teamId=null
teamId: null,
},
});
routingFormResponse = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
nonOrgAdminData.routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
formId: routingForm.id,
...routingFormData,
routes: [
{
...routingFormData.routes[0],
action: {
type: "eventTypeRedirectUrl",
eventTypeId: nonOrgAdminData.eventType.id,
value: `${nonOrgAdminData.user.username}/${nonOrgAdminData.eventType.slug}`,
},
},
routingFormData.routes[1],
],
userId: nonOrgAdminData.user.id,
teamId: null,
},
});
// Patch response and get Responses endpoints right now work for teams only
// We need to fix them in a followup PR
teamData.routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
...routingFormData,
routes: [
{
...routingFormData.routes[0],
action: {
type: "eventTypeRedirectUrl",
eventTypeId: orgAdminData.eventType.id,
value: `team/${teamData.team.slug}/${orgAdminData.eventType.slug}`,
},
},
routingFormData.routes[1],
],
userId: orgAdminData.user.id,
teamId: teamData.team.id,
},
});
teamData.routingFormResponse1 = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
data: {
formId: teamData.routingForm.id,
response: JSON.stringify({ question1: "answer1", question2: "answer2" }),
},
});
routingFormResponse2 = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
teamData.routingFormResponse2 = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
data: {
formId: routingForm.id,
formId: teamData.routingForm.id,
response: { question1: "answer1", question2: "answer2" },
},
});
@@ -196,33 +294,33 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
describe(`GET /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => {
it("should not get routing form responses for non existing org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/99999/routing-forms/${routingForm.id}/responses`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.get(`/v2/organizations/99999/routing-forms/${teamData.routingForm.id}/responses`)
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.expect(403);
});
it("should not get routing form responses for non existing form", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.expect(404);
});
it("should not get routing form responses without authentication", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses`)
.get(`/v2/organizations/${org.id}/routing-forms/${teamData.routingForm.id}/responses`)
.expect(401);
});
it("should get routing form responses", async () => {
const createdAt = new Date(routingFormResponse.createdAt);
const createdAt = new Date(teamData.routingFormResponse1.createdAt);
createdAt.setHours(createdAt.getHours() - 1);
const isoStringCreatedAt = createdAt.toISOString();
return request(app.getHttpServer())
.get(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?skip=0&take=2&sortUpdatedAt=asc&sortCreatedAt=desc&afterCreatedAt=${isoStringCreatedAt}`
`/v2/organizations/${org.id}/routing-forms/${teamData.routingForm.id}/responses?skip=0&take=2&sortUpdatedAt=asc&sortCreatedAt=desc&afterCreatedAt=${isoStringCreatedAt}`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.expect(200)
.then((response) => {
const responseBody = response.body;
@@ -230,14 +328,18 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
const responses = responseBody.data as App_RoutingForms_FormResponse[];
expect(responses).toBeDefined();
expect(responses.length).toBeGreaterThan(0);
expect(responses.find((response) => response.id === routingFormResponse.id)).toBeDefined();
expect(responses.find((response) => response.id === routingFormResponse.id)?.formId).toEqual(
routingFormResponse.formId
);
expect(responses.find((response) => response.id === routingFormResponse2.id)).toBeDefined();
expect(responses.find((response) => response.id === routingFormResponse2.id)?.formId).toEqual(
routingFormResponse2.formId
);
expect(
responses.find((response) => response.id === teamData.routingFormResponse1.id)
).toBeDefined();
expect(
responses.find((response) => response.id === teamData.routingFormResponse1.id)?.formId
).toEqual(teamData.routingFormResponse1.formId);
expect(
responses.find((response) => response.id === teamData.routingFormResponse2.id)
).toBeDefined();
expect(
responses.find((response) => response.id === teamData.routingFormResponse2.id)?.formId
).toEqual(teamData.routingFormResponse2.formId);
});
});
});
@@ -246,9 +348,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should return 403 when organization does not exist", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/99999/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
`/v2/organizations/99999/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "answer1",
})
@@ -260,7 +362,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
.post(
`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "answer1",
})
@@ -270,7 +372,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should return 401 when authentication token is missing", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.send({
question1: "answer1",
@@ -281,9 +383,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should create response and return available slots when routing to event type", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "answer1", // This matches the route condition
question2: "answer2",
@@ -296,7 +398,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
expect(data).toBeDefined();
expect(data.routing?.responseId).toBeDefined();
expect(typeof data.routing?.responseId).toBe("number");
expect(data.eventTypeId).toEqual(routingEventType.id);
expect(data.eventTypeId).toEqual(orgAdminData.eventType.id);
expect(data.slots).toBeDefined();
expect(typeof data.slots).toBe("object");
});
@@ -305,9 +407,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should return 400 when required form fields are missing", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question2: "answer2", // Missing required question1
})
@@ -317,9 +419,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should create response and return custom message if the routing is to custom page", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "different-answer", // This won't match any route
question2: "answer2",
@@ -338,8 +440,10 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should return 400 when required slot query parameters are missing", async () => {
// Missing start parameter
await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?end=2050-09-06`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.post(
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "answer1",
question2: "answer2",
@@ -348,8 +452,10 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
// Missing end parameter
await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.post(
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05`
)
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "answer1",
question2: "answer2",
@@ -360,9 +466,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should return 400 when date parameters have invalid format", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=invalid-date&end=2050-09-06`
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=invalid-date&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "answer1",
})
@@ -372,9 +478,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should return 400 when end date is before start date", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-10&end=2050-09-05`
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-10&end=2050-09-05`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "answer1",
})
@@ -394,7 +500,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
const response = await request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${unauthorizedApiKey}` })
.send({
@@ -412,9 +518,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should handle queued response creation", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06&queueResponse=true`
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06&queueResponse=true`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "answer1",
question2: "answer2",
@@ -430,67 +536,69 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should return 500 when event type is not found", async () => {
// Create a routing form with an invalid eventTypeId
const routingFormWithInvalidEventType = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
name: "Test Routing Form with Invalid Event Type",
description: "Test Description",
disabled: false,
routes: [
{
id: "route-1",
queryValue: {
const orgAdminUserRoutingFormWithInvalidEventType =
await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
name: "Test Routing Form with Invalid Event Type",
description: "Test Description",
disabled: false,
routes: [
{
id: "route-1",
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "question1",
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"],
queryValue: {
id: "route-1",
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "question1",
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
action: {
type: "eventTypeRedirectUrl",
eventTypeId: 99999, // Invalid event type ID
value: `team/${teamData.team.slug}/non-existent-event-type`,
},
isFallback: false,
},
action: {
type: "eventTypeRedirectUrl",
eventTypeId: 99999, // Invalid event type ID
value: `team/${team.slug}/non-existent-event-type`,
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" },
},
isFallback: false,
],
fields: [
{
id: "question1",
type: "text",
label: "Question 1",
required: true,
identifier: "question1",
},
],
settings: {
emailOwnerOnSubmission: false,
},
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" },
},
],
fields: [
{
id: "question1",
type: "text",
label: "Question 1",
required: true,
identifier: "question1",
},
],
settings: {
emailOwnerOnSubmission: false,
teamId: null,
userId: orgAdminData.user.id,
},
teamId: team.id,
userId: user.id,
},
});
});
// Try to create a response for the form with invalid event type
const response = await request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/routing-forms/${routingFormWithInvalidEventType.id}/responses?start=2050-09-05&end=2050-09-06`
`/v2/organizations/${org.id}/routing-forms/${orgAdminUserRoutingFormWithInvalidEventType.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "answer1",
});
@@ -499,37 +607,10 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
// Clean up the form
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
where: { id: routingFormWithInvalidEventType.id },
where: { id: orgAdminUserRoutingFormWithInvalidEventType.id },
});
});
it("should handle routing with team member assignments", async () => {
// This test verifies that routing forms can handle team member assignments
// and that the routing returns the correct team member information
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1", // This matches the route condition
question2: "answer2",
})
.expect(201)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data).toBeDefined();
expect(data.routing?.responseId).toBeDefined();
expect(typeof data.routing?.responseId).toBe("number");
expect(data.eventTypeId).toEqual(routingEventType.id);
expect(data.slots).toBeDefined();
// Team member assignments would be part of the routing data
// This test validates the basic routing functionality works
});
});
it("should return external redirect URL when routing to external URL", async () => {
// Create a routing form with external redirect action
const externalRoutingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
@@ -581,8 +662,8 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
settings: {
emailOwnerOnSubmission: false,
},
teamId: team.id,
userId: user.id,
teamId: null,
userId: orgAdminData.user.id,
},
});
@@ -590,7 +671,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
.post(
`/v2/organizations/${org.id}/routing-forms/${externalRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({
question1: "external", // This matches the route condition for external redirect
})
@@ -617,11 +698,161 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
});
});
describe(`POST /v2/organizations/:orgId/routing-forms/:routingFormId/responses - restrictions check`, () => {
// Helper functions to centralize API version header setting
const createAuthenticatedRequest = (method: "get" | "post" | "patch", url: string, apiKey: string) => {
return request(app.getHttpServer())
[method](url)
.set({
Authorization: `Bearer cal_test_${apiKey}`,
});
};
it("should allow non-org-admin user to access their own user routing form", async () => {
return createAuthenticatedRequest(
"post",
`/v2/organizations/${org.id}/routing-forms/${nonOrgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`,
nonOrgAdminData.apiKey
)
.send({
question1: "answer1",
question2: "answer2",
})
.expect(201)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data).toBeDefined();
expect(data.routing?.responseId).toBeDefined();
expect(typeof data.routing?.responseId).toBe("number");
expect(data.eventTypeId).toEqual(nonOrgAdminData.eventType.id);
expect(data.slots).toBeDefined();
expect(typeof data.slots).toBe("object");
});
});
it("should return 403 when non-org-admin user tries to access routing form they don't own", async () => {
// Create a second user
const otherUser = await userRepositoryFixture.create({
email: `other-user-${randomString()}@api.com`,
});
// Create a routing form that belongs to the other user
const otherorgAdminUserRoutingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
name: "Other User's Routing Form",
description: "Test Description",
disabled: false,
routes: [
{
id: "route-1",
queryValue: {
id: "route-1",
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "question1",
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
action: {
type: "eventTypeRedirectUrl",
eventTypeId: orgAdminData.eventType.id,
value: `team/${teamData.team.slug}/${orgAdminData.eventType.slug}`,
},
isFallback: false,
},
],
fields: [
{
id: "question1",
type: "text",
label: "Question 1",
required: true,
identifier: "question1",
},
],
settings: {
emailOwnerOnSubmission: false,
},
// User Routing Form has teamId=null
teamId: null,
userId: otherUser.id, // This form belongs to otherUser, not the authenticated user
},
});
// Try to access the routing form that belongs to the other user
const response = await createAuthenticatedRequest(
"post",
`/v2/organizations/${org.id}/routing-forms/${otherorgAdminUserRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06`,
nonOrgAdminData.apiKey
).send({
question1: "answer1",
});
expect(response.status).toBe(403);
expect(response.body.error.message).toContain(
`Routing Form with id=${otherorgAdminUserRoutingForm.id} is not a user Routing Form owned by user with id=${nonOrgAdminData.user.id}.`
);
// Clean up
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
where: { id: otherorgAdminUserRoutingForm.id },
});
await prismaWriteService.prisma.user.delete({
where: { id: otherUser.id },
});
});
it("should allow org admin to access any routing form using RolesGuard", async () => {
return createAuthenticatedRequest(
"post",
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`,
orgAdminData.apiKey
)
.send({
question1: "answer1",
question2: "answer2",
})
.expect(201)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data).toBeDefined();
expect(data.routing?.responseId).toBeDefined();
expect(typeof data.routing?.responseId).toBe("number");
});
});
afterAll(async () => {
// Clean up user routing form
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
where: { id: orgAdminData.routingForm.id },
});
// Clean up non-org-admin user
await prismaWriteService.prisma.user.delete({
where: { id: nonOrgAdminData.user.id },
});
});
});
describe(`PATCH /v2/organizations/:orgId/routing-forms/:routingFormId/responses/:responseId`, () => {
it("should not update routing form response for non existing org", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/99999/routing-forms/${routingForm.id}/responses/${routingFormResponse.id}`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.patch(
`/v2/organizations/99999/routing-forms/${teamData.routingForm.id}/responses/${teamData.routingFormResponse1.id}`
)
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
.expect(403);
});
@@ -629,17 +860,17 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should not update routing form response for non existing form", async () => {
return request(app.getHttpServer())
.patch(
`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses/${routingFormResponse.id}`
`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses/${teamData.routingFormResponse1.id}`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
.expect(404);
});
it("should not update routing form response for non existing response", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses/99999`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.patch(`/v2/organizations/${org.id}/routing-forms/${teamData.routingForm.id}/responses/99999`)
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
.expect(404);
});
@@ -647,7 +878,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should not update routing form response without authentication", async () => {
return request(app.getHttpServer())
.patch(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses/${routingFormResponse.id}`
`/v2/organizations/${org.id}/routing-forms/${teamData.routingForm.id}/responses/${teamData.routingFormResponse1.id}`
)
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
.expect(401);
@@ -657,9 +888,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
const updatedResponse = { question1: "updated_answer1", question2: "updated_answer2" };
return request(app.getHttpServer())
.patch(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses/${routingFormResponse.id}`
`/v2/organizations/${org.id}/routing-forms/${teamData.routingForm.id}/responses/${teamData.routingFormResponse1.id}`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
.send({ response: updatedResponse })
.expect(200)
.then((response) => {
@@ -667,8 +898,8 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data).toBeDefined();
expect(data.id).toEqual(routingFormResponse.id);
expect(data.formId).toEqual(routingFormResponse.formId);
expect(data.id).toEqual(teamData.routingFormResponse1.id);
expect(data.formId).toEqual(teamData.routingFormResponse1.formId);
expect(data.response).toEqual(updatedResponse);
});
});
@@ -677,12 +908,12 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
afterAll(async () => {
await prismaWriteService.prisma.app_RoutingForms_FormResponse.delete({
where: {
id: routingFormResponse.id,
id: teamData.routingFormResponse1.id,
},
});
await prismaWriteService.prisma.app_RoutingForms_FormResponse.delete({
where: {
id: routingFormResponse2.id,
id: teamData.routingFormResponse2.id,
},
});
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
@@ -697,7 +928,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
});
await prismaWriteService.prisma.team.delete({
where: {
id: team.id,
id: teamData.team.id,
},
});
await prismaWriteService.prisma.team.delete({
@@ -707,7 +938,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
});
await prismaWriteService.prisma.user.delete({
where: {
id: user.id,
id: orgAdminData.user.id,
},
});
@@ -4,8 +4,10 @@ import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.de
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { Or } from "@/modules/auth/guards/or-guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { IsUserRoutingForm } from "@/modules/auth/guards/organizations/is-user-routing-form.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { GetRoutingFormResponsesOutput } from "@/modules/organizations/routing-forms/outputs/get-routing-form-responses.output";
import { OrganizationsRoutingFormsResponsesService } from "@/modules/organizations/routing-forms/services/organizations-routing-forms-responses.service";
@@ -20,12 +22,12 @@ import {
UseGuards,
ParseIntPipe,
Req,
Version,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
import { Request } from "express";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetAvailableSlotsInput_2024_09_04 } from "@calcom/platform-types";
import { CreateRoutingFormResponseInput } from "../inputs/create-routing-form-response.input";
import { GetRoutingFormResponsesParams } from "../inputs/get-routing-form-responses-params.input";
@@ -37,7 +39,7 @@ import { UpdateRoutingFormResponseOutput } from "../outputs/update-routing-form-
path: "/v2/organizations/:orgId/routing-forms/:routingFormId/responses",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@ApiTags("Orgs / Routing forms")
@ApiHeader(API_KEY_HEADER)
export class OrganizationsRoutingFormsResponsesController {
@@ -48,6 +50,7 @@ export class OrganizationsRoutingFormsResponsesController {
@Get("/")
@ApiOperation({ summary: "Get routing form responses" })
@Roles("ORG_ADMIN")
@UseGuards(RolesGuard)
@PlatformPlan("ESSENTIALS")
async getRoutingFormResponses(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -74,6 +77,7 @@ export class OrganizationsRoutingFormsResponsesController {
@Post("/")
@ApiOperation({ summary: "Create routing form response and get available slots" })
@Roles("ORG_ADMIN")
@UseGuards(Or([RolesGuard, IsUserRoutingForm]))
@PlatformPlan("ESSENTIALS")
async createRoutingFormResponse(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -82,7 +86,6 @@ export class OrganizationsRoutingFormsResponsesController {
@Req() request: Request
): Promise<CreateRoutingFormResponseOutput> {
const result = await this.organizationsRoutingFormsResponsesService.createRoutingFormResponseWithSlots(
orgId,
routingFormId,
query,
request
@@ -97,6 +100,7 @@ export class OrganizationsRoutingFormsResponsesController {
@Patch("/:responseId")
@ApiOperation({ summary: "Update routing form response" })
@Roles("ORG_ADMIN")
@UseGuards(RolesGuard)
@PlatformPlan("ESSENTIALS")
async updateRoutingFormResponse(
@Param("orgId", ParseIntPipe) orgId: number,
@@ -1,3 +1,5 @@
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { IsUserRoutingForm } from "@/modules/auth/guards/organizations/is-user-routing-form.guard";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
@@ -14,17 +16,21 @@ import { OrganizationsRoutingFormsController } from "./controllers/organizations
import { OrganizationsRoutingFormsRepository } from "./organizations-routing-forms.repository";
import { OrganizationsRoutingFormsResponsesService } from "./services/organizations-routing-forms-responses.service";
import { OrganizationsRoutingFormsService } from "./services/organizations-routing-forms.service";
import { SharedRoutingFormResponseService } from "./services/shared-routing-form-response.service";
@Module({
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule, SlotsModule_2024_09_04],
providers: [
IsUserRoutingForm,
MembershipsRepository,
OrganizationsRepository,
OrganizationsRoutingFormsRepository,
OrganizationsRoutingFormsService,
OrganizationsRoutingFormsResponsesService,
SharedRoutingFormResponseService,
OrganizationsTeamsRoutingFormsResponsesOutputService,
TeamsEventTypesRepository,
EventTypesRepository_2024_06_14,
],
controllers: [OrganizationsRoutingFormsController, OrganizationsRoutingFormsResponsesController],
})
@@ -1,18 +1,9 @@
import { OrganizationsRoutingFormsRepository } from "@/modules/organizations/routing-forms/organizations-routing-forms.repository";
import { SharedRoutingFormResponseService } from "@/modules/organizations/routing-forms/services/shared-routing-form-response.service";
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
import { SlotsService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots.service";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import {
BadRequestException,
Injectable,
NotFoundException,
InternalServerErrorException,
} from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { Request } from "express";
import { getRoutedUrl } from "@calcom/platform-libraries";
import { ById_2024_09_04_type } from "@calcom/platform-types";
import type { CreateRoutingFormResponseInput } from "../inputs/create-routing-form-response.input";
import type { CreateRoutingFormResponseOutputData } from "../outputs/create-routing-form-response.output";
@@ -21,8 +12,7 @@ export class OrganizationsRoutingFormsResponsesService {
constructor(
private readonly organizationsRoutingFormsRepository: OrganizationsRoutingFormsRepository,
private readonly outputService: OrganizationsTeamsRoutingFormsResponsesOutputService,
private readonly slotsService: SlotsService_2024_09_04,
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
private readonly sharedRoutingFormResponseService: SharedRoutingFormResponseService
) {}
async getOrganizationRoutingFormResponses(
@@ -51,6 +41,18 @@ export class OrganizationsRoutingFormsResponsesService {
return this.outputService.getRoutingFormResponses(responses);
}
async createRoutingFormResponseWithSlots(
routingFormId: string,
query: CreateRoutingFormResponseInput,
request: Request
): Promise<CreateRoutingFormResponseOutputData> {
return this.sharedRoutingFormResponseService.createRoutingFormResponseWithSlots(
routingFormId,
query,
request
);
}
async updateRoutingFormResponse(
orgId: number,
routingFormId: string,
@@ -68,201 +70,4 @@ export class OrganizationsRoutingFormsResponsesService {
return this.outputService.getRoutingFormResponses([updatedResponse])[0];
}
async createRoutingFormResponseWithSlots(
orgId: number,
routingFormId: string,
query: CreateRoutingFormResponseInput,
request: Request
): Promise<CreateRoutingFormResponseOutputData> {
const { queueResponse, ...slotsQuery } = query;
this.validateDateRange(slotsQuery.start, slotsQuery.end);
const { redirectUrl, customMessage } = await this.getRoutingUrl(
request,
routingFormId,
queueResponse ?? false
);
// If there is no redirect URL, then we have to show the message as that would be custom page message to be shown as per the route chosen
if (!redirectUrl) {
return {
routingCustomMessage: customMessage,
};
}
if (!this.isEventTypeRedirectUrl(redirectUrl)) {
return {
routingExternalRedirectUrl: redirectUrl.toString(),
};
}
// Extract event type information from the routed URL
const { eventTypeId, crmParams } = await this.extractEventTypeAndCrmParams(redirectUrl);
const paramsForGetAvailableSlots = {
type: ById_2024_09_04_type,
eventTypeId,
...slotsQuery,
...crmParams,
} as const;
// Get available slots using the slots service with CRM parameters
const slots = await this.slotsService.getAvailableSlots(paramsForGetAvailableSlots);
const teamMemberIds = crmParams.routedTeamMemberIds ?? [];
const teamMemberEmail = crmParams.teamMemberEmail ?? undefined;
const skipContactOwner = crmParams.skipContactOwner ?? undefined;
const queuedResponseId = crmParams.queuedFormResponseId ?? null;
const responseId = crmParams.routingFormResponseId ?? null;
const crmAppSlug = crmParams.crmAppSlug ?? undefined;
const crmOwnerRecordType = crmParams.crmOwnerRecordType ?? undefined;
if (responseId) {
return {
routing: {
responseId,
teamMemberEmail,
teamMemberIds,
skipContactOwner,
crmAppSlug,
crmOwnerRecordType,
},
eventTypeId,
slots,
};
}
if (!queuedResponseId) {
throw new InternalServerErrorException(
"No routing form response ID or queued form response ID could be found."
);
}
return {
routing: {
queuedResponseId,
teamMemberEmail,
teamMemberIds,
skipContactOwner,
crmAppSlug,
crmOwnerRecordType,
},
eventTypeId,
slots,
};
}
private validateDateRange(start: string, end: string) {
const startDate = new Date(start);
const endDate = new Date(end);
if (endDate < startDate) {
throw new BadRequestException("End date cannot be before start date.");
}
}
private async getRoutingUrl(request: Request, formId: string, queueResponse: boolean) {
const params = Object.fromEntries(new URLSearchParams(request.body));
const routedUrlData = await getRoutedUrl(
{
req: request,
query: { ...params, form: formId, ...(queueResponse && { "cal.queueFormResponse": "true" }) },
},
true
);
if (routedUrlData.notFound) {
throw new NotFoundException("Routing form not found.");
}
if (routedUrlData?.props?.errorMessage) {
throw new BadRequestException(routedUrlData.props.errorMessage);
}
const destination = routedUrlData?.redirect?.destination;
if (!destination) {
if (routedUrlData?.props?.message) {
return {
redirectUrl: null,
customMessage: routedUrlData.props.message,
};
}
// This should never happen because there is always a fallback route
throw new InternalServerErrorException("No route found.");
}
return {
redirectUrl: new URL(destination),
customMessage: null,
};
}
private async extractEventTypeAndCrmParams(routingUrl: URL) {
// Extract team and event type information
const { teamId, eventTypeSlug } = this.extractTeamIdAndEventTypeSlugFromRedirectUrl(routingUrl);
const eventType = await this.teamsEventTypesRepository.getEventTypeByTeamIdAndSlug(teamId, eventTypeSlug);
if (!eventType?.id) {
// This could only happen if the event-type earlier selected as route action was deleted
throw new InternalServerErrorException("Chosen event type not found.");
}
// Extract CRM parameters from URL
const urlParams = routingUrl.searchParams;
const crmParams = {
teamMemberEmail: urlParams.get("cal.crmContactOwnerEmail") || undefined,
routedTeamMemberIds: urlParams.get("cal.routedTeamMemberIds")
? urlParams
.get("cal.routedTeamMemberIds")!
.split(",")
.map((id) => parseInt(id))
: undefined,
routingFormResponseId: urlParams.get("cal.routingFormResponseId")
? parseInt(urlParams.get("cal.routingFormResponseId")!)
: undefined,
queuedFormResponseId: urlParams.get("cal.queuedFormResponseId")
? (urlParams.get("cal.queuedFormResponseId") as string)
: undefined,
skipContactOwner: urlParams.get("cal.skipContactOwner") === "true" ? true : false,
crmAppSlug: urlParams.get("cal.crmAppSlug") || undefined,
crmOwnerRecordType: urlParams.get("cal.crmContactOwnerRecordType") || undefined,
};
return {
eventTypeId: eventType.id,
crmParams,
};
}
private isEventTypeRedirectUrl(routingUrl: URL) {
const routingSearchParams = routingUrl.searchParams;
return routingSearchParams.get("cal.action") === "eventTypeRedirectUrl";
}
private extractTeamIdAndEventTypeSlugFromRedirectUrl(routingUrl: URL) {
const eventTypeSlug = this.extractEventTypeFromRoutedUrl(routingUrl);
const teamId = this.extractTeamIdFromRoutedUrl(routingUrl);
if (!teamId) {
throw new NotFoundException("Team ID not found in the routed URL.");
}
if (!eventTypeSlug) {
throw new NotFoundException("Event type slug not found in the routed URL.");
}
return { teamId, eventTypeSlug };
}
private extractTeamIdFromRoutedUrl(routingUrl: URL) {
const routingSearchParams = routingUrl.searchParams;
return Number(routingSearchParams.get("cal.teamId"));
}
private extractEventTypeFromRoutedUrl(routingUrl: URL) {
const pathNameParams = routingUrl.pathname.split("/");
return pathNameParams[pathNameParams.length - 1];
}
}
@@ -0,0 +1,227 @@
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { CreateRoutingFormResponseInput } from "@/modules/organizations/routing-forms/inputs/create-routing-form-response.input";
import { CreateRoutingFormResponseOutputData } from "@/modules/organizations/routing-forms/outputs/create-routing-form-response.output";
import { SlotsService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots.service";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import {
Injectable,
BadRequestException,
InternalServerErrorException,
NotFoundException,
} from "@nestjs/common";
import { Request } from "express";
import { getRoutedUrl } from "@calcom/platform-libraries";
import { ById_2024_09_04_type } from "@calcom/platform-types";
@Injectable()
export class SharedRoutingFormResponseService {
constructor(
private readonly slotsService: SlotsService_2024_09_04,
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
private readonly eventTypesRepository: EventTypesRepository_2024_06_14
) {}
async createRoutingFormResponseWithSlots(
routingFormId: string,
query: CreateRoutingFormResponseInput,
request: Request
): Promise<CreateRoutingFormResponseOutputData> {
const { queueResponse, ...slotsQuery } = query;
const user = request.user as ApiAuthGuardUser;
this.validateDateRange(slotsQuery.start, slotsQuery.end);
const { redirectUrl, customMessage } = await this.getRoutingUrl(
request,
routingFormId,
queueResponse ?? false
);
// If there is no redirect URL, then we have to show the message as that would be custom page message to be shown as per the route chosen
if (!redirectUrl) {
return {
routingCustomMessage: customMessage,
};
}
if (!this.isEventTypeRedirectUrl(redirectUrl)) {
return {
routingExternalRedirectUrl: redirectUrl.toString(),
};
}
// Extract event type information from the routed URL
const { eventTypeId, crmParams } = await this.extractEventTypeAndCrmParams(user.id, redirectUrl);
const paramsForGetAvailableSlots = {
type: ById_2024_09_04_type,
eventTypeId,
...slotsQuery,
...crmParams,
} as const;
// Get available slots using the slots service with CRM parameters
const slots = await this.slotsService.getAvailableSlots(paramsForGetAvailableSlots);
const teamMemberIds = crmParams.routedTeamMemberIds ?? [];
const teamMemberEmail = crmParams.teamMemberEmail ?? undefined;
const skipContactOwner = crmParams.skipContactOwner ?? undefined;
const queuedResponseId = crmParams.queuedFormResponseId ?? null;
const responseId = crmParams.routingFormResponseId ?? null;
const crmAppSlug = crmParams.crmAppSlug ?? undefined;
const crmOwnerRecordType = crmParams.crmOwnerRecordType ?? undefined;
if (responseId) {
return {
routing: {
responseId,
teamMemberEmail,
teamMemberIds,
skipContactOwner,
crmAppSlug,
crmOwnerRecordType,
},
eventTypeId,
slots,
};
}
if (!queuedResponseId) {
throw new InternalServerErrorException(
"No routing form response ID or queued form response ID could be found."
);
}
return {
routing: {
queuedResponseId,
teamMemberEmail,
teamMemberIds,
skipContactOwner,
crmAppSlug,
crmOwnerRecordType,
},
eventTypeId,
slots,
};
}
private validateDateRange(start: string, end: string) {
const startDate = new Date(start);
const endDate = new Date(end);
if (endDate < startDate) {
throw new BadRequestException("End date cannot be before start date.");
}
}
private async getRoutingUrl(request: Request, formId: string, queueResponse: boolean) {
const params = Object.fromEntries(new URLSearchParams(request.body));
const routedUrlData = await getRoutedUrl(
{
req: request,
query: { ...params, form: formId, ...(queueResponse && { "cal.queueFormResponse": "true" }) },
},
true
);
if (routedUrlData.notFound) {
throw new NotFoundException("Routing form not found.");
}
if (routedUrlData?.props?.errorMessage) {
throw new BadRequestException(routedUrlData.props.errorMessage);
}
const destination = routedUrlData?.redirect?.destination;
if (!destination) {
if (routedUrlData?.props?.message) {
return {
redirectUrl: null,
customMessage: routedUrlData.props.message,
};
}
// This should never happen because there is always a fallback route
throw new InternalServerErrorException("No route found.");
}
return {
redirectUrl: new URL(destination),
customMessage: null,
};
}
private async extractEventTypeAndCrmParams(userId: number, routingUrl: URL) {
// Extract team and event type information
// TODO: Route action also has eventTypeId directly now and instead of using this brittle approach for getting event type by slug, we should get by eventTypeId
const { teamId, eventTypeSlug } = this.extractTeamIdAndEventTypeSlugFromRedirectUrl(routingUrl);
const eventType = teamId
? await this.teamsEventTypesRepository.getEventTypeByTeamIdAndSlug(teamId, eventTypeSlug)
: await this.eventTypesRepository.getUserEventTypeBySlug(userId, eventTypeSlug);
if (!eventType?.id) {
// This could only happen if the event-type earlier selected as route action was deleted
throw new InternalServerErrorException(
`Chosen event type identified by slug ${eventTypeSlug} not found.`
);
}
// Extract CRM parameters from URL
const urlParams = routingUrl.searchParams;
const crmParams = {
teamMemberEmail: urlParams.get("cal.crmContactOwnerEmail") || undefined,
routedTeamMemberIds: urlParams.get("cal.routedTeamMemberIds")
? urlParams
.get("cal.routedTeamMemberIds")!
.split(",")
.map((id) => parseInt(id))
: undefined,
routingFormResponseId: urlParams.get("cal.routingFormResponseId")
? parseInt(urlParams.get("cal.routingFormResponseId")!)
: undefined,
queuedFormResponseId: urlParams.get("cal.queuedFormResponseId")
? (urlParams.get("cal.queuedFormResponseId") as string)
: undefined,
skipContactOwner: urlParams.get("cal.skipContactOwner") === "true" ? true : false,
crmAppSlug: urlParams.get("cal.crmAppSlug") || undefined,
crmOwnerRecordType: urlParams.get("cal.crmContactOwnerRecordType") || undefined,
};
return {
eventTypeId: eventType.id,
crmParams,
};
}
private isEventTypeRedirectUrl(routingUrl: URL) {
const routingSearchParams = routingUrl.searchParams;
return routingSearchParams.get("cal.action") === "eventTypeRedirectUrl";
}
private extractTeamIdAndEventTypeSlugFromRedirectUrl(routingUrl: URL) {
const eventTypeSlug = this.extractEventTypeFromRoutedUrl(routingUrl);
const teamId = this.extractTeamIdFromRoutedUrl(routingUrl);
if (!eventTypeSlug) {
throw new InternalServerErrorException("Event type slug not found in the routed URL.");
}
return { teamId, eventTypeSlug };
}
private extractTeamIdFromRoutedUrl(routingUrl: URL) {
const routingSearchParams = routingUrl.searchParams;
const teamId = Number(routingSearchParams.get("cal.teamId"));
if (isNaN(teamId)) {
return null;
}
return teamId;
}
private extractEventTypeFromRoutedUrl(routingUrl: URL) {
const pathNameParams = routingUrl.pathname.split("/");
return pathNameParams[pathNameParams.length - 1];
}
}
@@ -1,6 +1,7 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { GetRoutingFormResponsesOutput } from "@/modules/organizations/teams/routing-forms/outputs/get-routing-form-responses.output";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -23,6 +24,7 @@ import { Team } from "@calcom/prisma/client";
describe("Organizations Teams Routing Forms Responses", () => {
let app: INestApplication;
let prismaWriteService: PrismaWriteService;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
@@ -41,6 +43,13 @@ describe("Organizations Teams Routing Forms Responses", () => {
let apiKeyString: string;
let routingFormId: string;
let routingEventType: {
id: number;
slug: string | null;
teamId: number | null;
userId: number | null;
title: string;
};
const routingFormResponses = [
{
formFillerId: `${randomString()}`,
@@ -59,6 +68,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
}).compile();
prismaWriteService = moduleRef.get<PrismaWriteService>(PrismaWriteService);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
@@ -98,6 +108,17 @@ describe("Organizations Teams Routing Forms Responses", () => {
team: { connect: { id: orgTeam.id } },
});
// Create an event type for routing form to route to
routingEventType = await prismaWriteService.prisma.eventType.create({
data: {
title: "Test Event Type",
slug: "test-event-type",
length: 30,
userId: user.id,
teamId: orgTeam.id,
},
});
await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: authEmail,
@@ -118,18 +139,59 @@ describe("Organizations Teams Routing Forms Responses", () => {
description: null,
position: 0,
disabled: false,
fields: JSON.stringify([
fields: [
{
id: "participant-field",
type: "text",
label: "participant",
required: true,
identifier: "participant",
},
]),
routes: JSON.stringify([
{
action: { type: "customPageMessage", value: "Thank you for your response" },
id: "question2-field",
type: "text",
label: "question2",
required: false,
identifier: "question2",
},
]),
],
routes: [
{
id: "route-1",
queryValue: {
id: "route-1",
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "question1",
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
action: {
type: "eventTypeRedirectUrl",
eventTypeId: routingEventType.id,
value: `team/${orgTeam.slug}/${routingEventType.slug}`,
},
isFallback: false,
},
{
id: "fallback-route",
queryValue: {
id: "fallback-route",
type: "group",
children1: {},
},
action: { type: "customPageMessage", value: "Thank you for your response" },
isFallback: true,
},
],
user: {
connect: {
id: user.id,
@@ -190,6 +252,455 @@ describe("Organizations Teams Routing Forms Responses", () => {
});
});
describe(`POST /v2/organizations/:orgId/teams/:teamId/routing-forms/:routingFormId/responses`, () => {
describe("permissions", () => {
it("should return 403 when organization does not exist", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/99999/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
})
.expect(403);
});
it("should return 404 when team does not exist", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/99999/routing-forms/${routingFormId}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
})
.expect(404)
.then((response) => {
expect(response.body.error.message).toContain(`IsTeamInOrg - Team (99999) not found.`);
});
});
it("should return 403 when routing form does not exist in the team", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/non-existent-id/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
})
.expect(403)
.then((response) => {
expect(response.body.error.message).toContain(
`IsRoutingFormInTeam - team with id=(${orgTeam.id}) does not own routing form with id=(non-existent-id).`
);
});
});
it("should return 403 when routing form belongs to different team", async () => {
// Create a second team within the same organization
const otherTeam = await teamsRepositoryFixture.create({
name: `other-team-${randomString()}`,
isOrganization: false,
parent: { connect: { id: org.id } },
});
// Create a routing form that belongs to the other team
const otherTeamRoutingForm = await routingFormsRepositoryFixture.create({
name: "Other Team's Routing Form",
description: "Test Description",
position: 0,
disabled: false,
fields: [
{
type: "text",
label: "Question 1",
required: true,
},
],
routes: [
{
action: { type: "customPageMessage", value: "Thank you for your response" },
},
],
user: {
connect: {
id: user.id,
},
},
team: {
connect: {
id: otherTeam.id,
},
},
});
// Try to access the routing form that belongs to the other team
const response = await request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${otherTeamRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
});
expect(response.status).toBe(403);
expect(response.body.error.message).toContain(
`IsRoutingFormInTeam - team with id=(${orgTeam.id}) does not own routing form with id=(${otherTeamRoutingForm.id}).`
);
// Clean up
await routingFormsRepositoryFixture.delete(otherTeamRoutingForm.id);
await teamsRepositoryFixture.delete(otherTeam.id);
});
it("should return 401 when authentication token is missing", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?start=2050-09-05&end=2050-09-06`
)
.send({
question1: "answer1",
})
.expect(401);
});
});
it("should return 400 when required form fields are missing", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
// Missing required participant field
})
.expect(400);
});
it("should return 400 when required slot query parameters are missing", async () => {
// Missing start parameter
await request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
participant: "test-participant",
})
.expect(400);
// Missing end parameter
await request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?start=2050-09-05`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
participant: "test-participant",
})
.expect(400);
});
it("should return 400 when date parameters have invalid format", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?start=invalid-date&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
participant: "test-participant",
})
.expect(400);
});
it("should return 400 when end date is before start date", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?start=2050-09-10&end=2050-09-05`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
participant: "test-participant",
})
.expect(400);
});
it("should handle queued response creation", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?start=2050-09-05&end=2050-09-06&queueResponse=true`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
participant: "test-participant",
})
.expect(201)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
console.log("responseBody", responseBody.data);
expect(data.routing?.queuedResponseId).toBeDefined();
});
});
it("should create response and return available slots when routing to event type", async () => {
// Create a routing form with event type routing
const eventTypeRoutingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
name: "Test Event Type Routing Form",
description: "Test Description",
disabled: false,
routes: [
{
id: "route-1",
queryValue: {
id: "route-1",
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "question1",
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
action: {
type: "eventTypeRedirectUrl",
eventTypeId: routingEventType.id,
value: `team/${orgTeam.slug}/${routingEventType.slug}`,
},
isFallback: false,
},
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" },
},
],
fields: [
{
id: "question1",
type: "text",
label: "Question 1",
required: true,
identifier: "question1",
},
{
id: "question2",
type: "text",
label: "Question 2",
required: false,
identifier: "question2",
},
],
settings: {
emailOwnerOnSubmission: false,
},
teamId: orgTeam.id,
userId: user.id,
},
});
const response = await request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${eventTypeRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1", // This matches the route condition
question2: "answer2",
})
.expect(201);
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data).toBeDefined();
expect(data.routing?.responseId).toBeDefined();
expect(typeof data.routing?.responseId).toBe("number");
expect(data.eventTypeId).toEqual(routingEventType.id);
expect(data.slots).toBeDefined();
expect(typeof data.slots).toBe("object");
expect(data.routing?.teamMemberIds).toBeDefined();
// Clean up
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
where: { id: eventTypeRoutingForm.id },
});
});
it("should return 500 when event type is not found", async () => {
// Create a routing form with an invalid eventTypeId
const routingFormWithInvalidEventType = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
name: "Test Routing Form with Invalid Event Type",
description: "Test Description",
disabled: false,
routes: [
{
id: "route-1",
queryValue: {
id: "route-1",
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "question1",
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
action: {
type: "eventTypeRedirectUrl",
eventTypeId: 99999, // Invalid event type ID
value: `team/${orgTeam.slug}/non-existent-event-type`,
},
isFallback: false,
},
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" },
},
],
fields: [
{
id: "question1",
type: "text",
label: "Question 1",
required: true,
identifier: "question1",
},
],
settings: {
emailOwnerOnSubmission: false,
},
teamId: orgTeam.id,
userId: user.id,
},
});
// Try to create a response for the form with invalid event type
const response = await request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormWithInvalidEventType.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
});
expect(response.status).toBe(500);
// Clean up the form
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
where: { id: routingFormWithInvalidEventType.id },
});
});
it("should return external redirect URL when routing to external URL", async () => {
// Create a routing form with external redirect action
const externalRoutingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
name: "Test External Routing Form",
description: "Test Description for External Redirect",
disabled: false,
routes: [
{
id: "external-route-1",
queryValue: {
id: "external-route-1",
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "question1",
operator: "equal",
value: ["external"],
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
action: {
type: "externalRedirectUrl",
value: "https://example.com/external-booking",
},
isFallback: false,
},
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" },
},
],
fields: [
{
id: "question1",
type: "text",
label: "Question 1",
required: true,
identifier: "question1",
},
],
settings: {
emailOwnerOnSubmission: false,
},
teamId: orgTeam.id,
userId: user.id,
},
});
const response = await request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${externalRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "external", // This matches the route condition for external redirect
})
.expect(201);
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data).toBeDefined();
expect(data.routingExternalRedirectUrl).toBeDefined();
expect(data.routingExternalRedirectUrl).toContain("https://example.com/external-booking");
expect(data.routingExternalRedirectUrl).toContain("cal.action=externalRedirectUrl");
// Verify that it doesn't contain event type routing data
expect(data.eventTypeId).toBeUndefined();
expect(data.slots).toBeUndefined();
expect(data.routing).toBeUndefined();
expect(data.routingCustomMessage).toBeUndefined();
// Clean up the external routing form
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
where: { id: externalRoutingForm.id },
});
});
});
describe(`PATCH /v2/organizations/:orgId/routing-forms/:routingFormId/responses/:responseId`, () => {
it("should not update routing form response for non existing org", async () => {
return request(app.getHttpServer())
@@ -246,8 +757,13 @@ describe("Organizations Teams Routing Forms Responses", () => {
});
afterAll(async () => {
await routingFormsRepositoryFixture.deleteResponse(routingFormResponseId);
if (routingFormResponseId) {
await routingFormsRepositoryFixture.deleteResponse(routingFormResponseId);
}
await routingFormsRepositoryFixture.delete(routingFormId);
await prismaWriteService.prisma.eventType.delete({
where: { id: routingEventType.id },
});
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
@@ -9,13 +9,27 @@ import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsRoutingFormInTeam } from "@/modules/auth/guards/routing-forms/is-routing-form-in-team.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { CreateRoutingFormResponseInput } from "@/modules/organizations/routing-forms/inputs/create-routing-form-response.input";
import { GetRoutingFormResponsesParams } from "@/modules/organizations/routing-forms/inputs/get-routing-form-responses-params.input";
import { UpdateRoutingFormResponseInput } from "@/modules/organizations/routing-forms/inputs/update-routing-form-response.input";
import { CreateRoutingFormResponseOutput } from "@/modules/organizations/routing-forms/outputs/create-routing-form-response.output";
import { UpdateRoutingFormResponseOutput } from "@/modules/organizations/routing-forms/outputs/update-routing-form-response.output";
import { GetRoutingFormResponsesOutput } from "@/modules/organizations/teams/routing-forms/outputs/get-routing-form-responses.output";
import { OrganizationsTeamsRoutingFormsResponsesService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses.service";
import { Controller, Get, Patch, Param, ParseIntPipe, Query, UseGuards, Body } from "@nestjs/common";
import {
Controller,
Get,
Post,
Patch,
Param,
ParseIntPipe,
Query,
UseGuards,
Body,
Req,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
import { Request } from "express";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
@@ -66,6 +80,30 @@ export class OrganizationsTeamsRoutingFormsResponsesController {
};
}
@Post("/")
@ApiOperation({ summary: "Create routing form response and get available slots" })
@Roles("TEAM_MEMBER")
@PlatformPlan("ESSENTIALS")
async createRoutingFormResponse(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Param("routingFormId") routingFormId: string,
@Query() query: CreateRoutingFormResponseInput,
@Req() request: Request
): Promise<CreateRoutingFormResponseOutput> {
const result =
await this.organizationsTeamsRoutingFormsResponsesService.createRoutingFormResponseWithSlots(
routingFormId,
query,
request
);
return {
status: SUCCESS_STATUS,
data: result,
};
}
@Patch("/:responseId")
@ApiOperation({ summary: "Update routing form response" })
@Roles("TEAM_ADMIN")
@@ -1,7 +1,9 @@
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsRoutingFormsRepository } from "@/modules/organizations/routing-forms/organizations-routing-forms.repository";
import { OrganizationsRoutingFormsResponsesService } from "@/modules/organizations/routing-forms/services/organizations-routing-forms-responses.service";
import { SharedRoutingFormResponseService } from "@/modules/organizations/routing-forms/services/shared-routing-form-response.service";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
@@ -31,6 +33,7 @@ import { OrganizationsTeamsRoutingFormsService } from "./services/organizations-
providers: [
OrganizationsTeamsRoutingFormsService,
OrganizationsTeamsRoutingFormsResponsesService,
SharedRoutingFormResponseService,
OrganizationsTeamsRoutingFormsResponsesOutputService,
OrganizationsTeamsRoutingFormsResponsesRepository,
OrganizationsTeamsRoutingFormsRepository,
@@ -39,6 +42,7 @@ import { OrganizationsTeamsRoutingFormsService } from "./services/organizations-
MembershipsRepository,
OrganizationsRoutingFormsResponsesService,
OrganizationsRoutingFormsRepository,
EventTypesRepository_2024_06_14,
],
controllers: [OrganizationsTeamsRoutingFormsResponsesController, OrganizationsTeamsRoutingFormsController],
})
@@ -1,5 +1,9 @@
import { CreateRoutingFormResponseInput } from "@/modules/organizations/routing-forms/inputs/create-routing-form-response.input";
import { CreateRoutingFormResponseOutputData } from "@/modules/organizations/routing-forms/outputs/create-routing-form-response.output";
import { SharedRoutingFormResponseService } from "@/modules/organizations/routing-forms/services/shared-routing-form-response.service";
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
import { Injectable } from "@nestjs/common";
import { Request } from "express";
import { OrganizationsTeamsRoutingFormsResponsesRepository } from "../repositories/organizations-teams-routing-forms-responses.repository";
@@ -7,7 +11,8 @@ import { OrganizationsTeamsRoutingFormsResponsesRepository } from "../repositori
export class OrganizationsTeamsRoutingFormsResponsesService {
constructor(
private readonly routingFormsRepository: OrganizationsTeamsRoutingFormsResponsesRepository,
private readonly routingFormsResponsesOutputService: OrganizationsTeamsRoutingFormsResponsesOutputService
private readonly routingFormsResponsesOutputService: OrganizationsTeamsRoutingFormsResponsesOutputService,
private readonly sharedRoutingFormResponseService: SharedRoutingFormResponseService
) {}
async getTeamRoutingFormResponses(
@@ -36,6 +41,18 @@ export class OrganizationsTeamsRoutingFormsResponsesService {
return this.routingFormsResponsesOutputService.getRoutingFormResponses(responses);
}
async createRoutingFormResponseWithSlots(
routingFormId: string,
query: CreateRoutingFormResponseInput,
request: Request
): Promise<CreateRoutingFormResponseOutputData> {
return this.sharedRoutingFormResponseService.createRoutingFormResponseWithSlots(
routingFormId,
query,
request
);
}
async updateTeamRoutingFormResponse(
teamId: number,
routingFormId: string,