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:
@@ -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;
|
||||
}
|
||||
}
|
||||
+438
-207
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+7
-3
@@ -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,
|
||||
|
||||
+6
@@ -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],
|
||||
})
|
||||
|
||||
+15
-210
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
+227
@@ -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];
|
||||
}
|
||||
}
|
||||
+522
-6
@@ -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();
|
||||
|
||||
+39
-1
@@ -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")
|
||||
|
||||
+4
@@ -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],
|
||||
})
|
||||
|
||||
+18
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user