Skip to content

feat: add organization routing forms endpoints [v2] #20580

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 26 commits into from
Apr 11, 2025
Merged
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a3f0baf
feat: add organization routing forms endpoints
devin-ai-integration[bot] Apr 7, 2025
b286d5a
Merge branch 'main' into devin/1744015226-routing-forms-endpoints
ThyMinimalDev Apr 7, 2025
33b5768
Merge branch 'main' into devin/1744015226-routing-forms-endpoints
ThyMinimalDev Apr 8, 2025
3f3db26
Merge branch 'main' into devin/1744015226-routing-forms-endpoints
SomayChauhan Apr 9, 2025
32b1ac1
refactor: reorganize team routing forms modules and add new endpoints
SomayChauhan Apr 9, 2025
0f51d38
fix: routing form responses and updated at
ThyMinimalDev Apr 9, 2025
f26d71b
fix: OrganizationsRoutingFormController e2e test
ThyMinimalDev Apr 9, 2025
81e1333
refactor and fix stuff
ThyMinimalDev Apr 9, 2025
5da33fa
Merge branch 'main' into devin/1744015226-routing-forms-endpoints
ThyMinimalDev Apr 9, 2025
90e3ad1
remove not needed files and fix comments
ThyMinimalDev Apr 9, 2025
30fdab2
fix OrganizationsTeamsRoutingFormsResponsesController
ThyMinimalDev Apr 9, 2025
0d45542
fix tests
ThyMinimalDev Apr 9, 2025
7ba1a26
fix tests
ThyMinimalDev Apr 9, 2025
ab6c6c7
Merge branch 'main' into devin/1744015226-routing-forms-endpoints
ThyMinimalDev Apr 9, 2025
ac7a2f3
fix: get routing form output
ThyMinimalDev Apr 10, 2025
2f37f06
remove log
ThyMinimalDev Apr 10, 2025
6f682a6
remove now
ThyMinimalDev Apr 10, 2025
9d24855
fix getTeamRoutingForms types
ThyMinimalDev Apr 10, 2025
f2c2693
Merge branch 'main' into devin/1744015226-routing-forms-endpoints
ThyMinimalDev Apr 10, 2025
34087e7
refactor: update routing forms API endpoints and repository methods
SomayChauhan Apr 10, 2025
57edcfd
added `teamIds?: number[]` filter
SomayChauhan Apr 10, 2025
8de80a8
Update get-routing-form-responses-params.input.ts
SomayChauhan Apr 10, 2025
7fdb87c
Update get-routing-form-responses-params.input.ts
SomayChauhan Apr 10, 2025
76afec2
Merge branch 'main' into devin/1744015226-routing-forms-endpoints
ThyMinimalDev Apr 11, 2025
8ed0c40
fix e2e
ThyMinimalDev Apr 11, 2025
c7c886e
add Patch team routing form responses e2e
ThyMinimalDev Apr 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/v2/src/modules/endpoints.module.ts
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@ import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
import { DestinationCalendarsModule } from "@/modules/destination-calendars/destination-calendars.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { OrganizationsBookingsModule } from "@/modules/organizations/bookings/organizations.bookings.module";
import { OrganizationsRoutingFormsModule } from "@/modules/organizations/routing-forms/organizations-routing-forms.module";
import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.module";
import { OrganizationsUsersBookingsModule } from "@/modules/organizations/users/bookings/organizations-users-bookings.module";
import { RouterModule } from "@/modules/router/router.module";
@@ -31,6 +32,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
OrganizationsTeamsBookingsModule,
OrganizationsUsersBookingsModule,
OrganizationsBookingsModule,
OrganizationsRoutingFormsModule,
RouterModule,
],
})
Original file line number Diff line number Diff line change
@@ -37,7 +37,7 @@ import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/s
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms-responses.module";
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
import { OrganizationsTeamsSchedulesController } from "@/modules/organizations/teams/schedules/organizations-teams-schedules.controller";
import { OrganizationsUsersController } from "@/modules/organizations/users/index/controllers/organizations-users.controller";
import { OrganizationsUsersRepository } from "@/modules/organizations/users/index/organizations-users.repository";
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
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";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { App_RoutingForms_Form, App_RoutingForms_FormResponse, Team, User } from "@prisma/client";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";

import { SUCCESS_STATUS } from "@calcom/platform-constants";

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;

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 membershipsRepositoryFixture: MembershipRepositoryFixture;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
}).compile();

teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);

prismaWriteService = moduleRef.get<PrismaWriteService>(PrismaWriteService);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);

membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: `OrganizationsRoutingFormsResponsesController-teams-memberships-organization-${randomString()}`,
isOrganization: true,
});

team = await teamRepositoryFixture.create({
name: "OrganizationsRoutingFormsResponsesController orgs booking 1",
isOrganization: false,
parent: { connect: { id: org.id } },
});

user = await userRepositoryFixture.create({
email: userEmail,
});

await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});

await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: userEmail,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: user.id,
},
},
});
const now = new Date();
now.setDate(now.getDate() + 1);
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = `${keyString}`;

routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
name: "Test Routing Form",
description: "Test Description",
disabled: false,
routes: JSON.stringify([]),
fields: JSON.stringify([]),
settings: JSON.stringify({}),
teamId: team.id,
userId: user.id,
},
});

routingFormResponse = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
data: {
formId: routingForm.id,
response: JSON.stringify({ question1: "answer1", question2: "answer2" }),
},
});

routingFormResponse2 = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
data: {
formId: routingForm.id,
response: { question1: "answer1", question2: "answer2" },
},
});

app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);

await app.init();
});

afterAll(async () => {
await prismaWriteService.prisma.app_RoutingForms_FormResponse.deleteMany({
where: {
formId: routingForm.id,
},
});
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.apiKey.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: team.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: org.id,
},
});

await app.close();
});

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}` })
.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}` })
.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`)
.expect(401);
});

it("should get routing form responses", async () => {
return request(app.getHttpServer())
.get(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?skip=0&take=2&sortUpdatedAt=asc&sortCreatedAt=desc`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
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
);
});
});
});

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}` })
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
.expect(403);
});

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}`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.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}` })
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
.expect(404);
});

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}`
)
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
.expect(401);
});

it("should update routing form response", async () => {
const updatedResponse = { question1: "updated_answer1", question2: "updated_answer2" };
return request(app.getHttpServer())
.patch(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses/${routingFormResponse.id}`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({ response: updatedResponse })
.expect(200)
.then((response) => {
const responseBody = response.body;
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.response).toEqual(updatedResponse);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { API_KEY_HEADER } from "@/lib/docs/headers";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
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 { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.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";
import { Body, Controller, Get, Param, Patch, Query, UseGuards, ParseIntPipe } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";

import { SUCCESS_STATUS } from "@calcom/platform-constants";

import { GetRoutingFormResponsesParams } from "../inputs/get-routing-form-responses-params.input";
import { UpdateRoutingFormResponseInput } from "../inputs/update-routing-form-response.input";
import { UpdateRoutingFormResponseOutput } from "../outputs/update-routing-form-response.output";

@Controller({
path: "/v2/organizations/:orgId/routing-forms/:routingFormId/responses",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@ApiTags("Orgs / Routing forms")
@ApiHeader(API_KEY_HEADER)
export class OrganizationsRoutingFormsResponsesController {
constructor(
private readonly organizationsRoutingFormsResponsesService: OrganizationsRoutingFormsResponsesService
) {}

@Get("/")
@ApiOperation({ summary: "Get routing form responses" })
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
async getRoutingFormResponses(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("routingFormId") routingFormId: string,
@Query() queryParams: GetRoutingFormResponsesParams
): Promise<GetRoutingFormResponsesOutput> {
const { skip, take, ...filters } = queryParams;

const responses =
await this.organizationsRoutingFormsResponsesService.getOrganizationRoutingFormResponses(
orgId,
routingFormId,
skip ?? 0,
take ?? 250,
filters
);

return {
status: SUCCESS_STATUS,
data: responses,
};
}

@Patch("/:responseId")
@ApiOperation({ summary: "Update routing form response" })
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
async updateRoutingFormResponse(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("routingFormId") routingFormId: string,
@Param("responseId", ParseIntPipe) responseId: number,
@Body() updateRoutingFormResponseInput: UpdateRoutingFormResponseInput
): Promise<UpdateRoutingFormResponseOutput> {
const updatedResponse = await this.organizationsRoutingFormsResponsesService.updateRoutingFormResponse(
orgId,
routingFormId,
responseId,
updateRoutingFormResponseInput
);

return {
status: SUCCESS_STATUS,
data: updatedResponse,
};
}
}
Loading