Files
calendar/apps/api/v2/src/modules/router/controllers/router.controller.ts
T
sean-brydonGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
19563aa697 feat: Self hosted onboarding (#22102)
* intro work

* update wixard form to have content callback to remove preset navigation

* more fixes to deployment

* fix calling static service

* fix save license key text

* ensure default steps work as expected

* fix conditional for rendering step

* skip step

* add on next step for free license

* refactor wizard form to use nuqs

* fix styles

* merge base param with step config

* fix next stepo text

* use deployment Signature token

* decrypt signature token

* fix: resolve type errors and test failures from wizard form refactor

- Fix signatureToken field name to signatureTokenEncrypted in deployment repository
- Add missing getSignatureToken method to verifyApiKey test mock
- Fix WizardForm import from default to named export in test file
- Add missing nextStep prop to Steps component in WizardForm

Resolves TypeScript type check errors and unit test failures without changing functionality.

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* fix: add missing getDeploymentSignatureToken mock in LicenseKeyService test

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* fix: add nuqs library mock for WizardForm test

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* fix: add missing nav prop to AdminAppsList component with eslint disable

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* Apply suggestions from code review

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update apps/web/modules/auth/setup-view.tsx

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* fix license schema changes

* revret schema generation

* fix eslint errors

* remove required nav type + add use client

* fix types

* Update packages/ui/components/form/wizard/useWizardState.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* fix controller issue

* add checks for deployment key being null - add more tests

* fix tests

* add deployment key tests

* fix: resolve crypto mock to handle empty encryption keys gracefully

- Updated symmetricDecrypt mock to return null instead of throwing 'Invalid key' error when encryption key is empty
- All getDeploymentKey tests now pass including the previously failing 'should return null when decryption fails due to missing encryption key' test
- Fixes mocking issues in PR 22102 self-hosted onboarding wizard form refactor

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* fix label

* add i18n to error

* use enum for steps

* add as const

* fix test env issues

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-07-09 09:26:01 +01:00

103 lines
3.9 KiB
TypeScript

import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { Controller, Req, NotFoundException, Param, Post, Body } from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { Request } from "express";
import {
getRoutedUrl,
getTeamMemberEmailForResponseOrContactUsingUrlQuery,
} from "@calcom/platform-libraries";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "/v2/router",
version: API_VERSIONS_VALUES,
})
@DocsTags("Router controller")
@DocsExcludeController(true)
export class RouterController {
constructor(private readonly teamsEventTypesRepository: TeamsEventTypesRepository) {}
@Post("/forms/:formId/submit")
async getRoutingFormResponse(
@Req() request: Request,
@Param("formId") formId: string,
@Body() body?: Record<string, string>
): Promise<void | (ApiResponse<unknown> & { redirect: boolean })> {
const params = Object.fromEntries(new URLSearchParams(body ?? {}));
const routedUrlData = await getRoutedUrl({ req: request, query: { ...params, form: formId } });
if (routedUrlData?.notFound) {
throw new NotFoundException("Route not found. Please check the provided form parameter.");
}
if (routedUrlData?.redirect?.destination) {
return this.handleRedirect(routedUrlData.redirect.destination);
}
if (routedUrlData?.props) {
if (routedUrlData.props.errorMessage) {
return {
status: "error",
error: { code: "ROUTING_ERROR", message: routedUrlData.props.errorMessage },
redirect: false,
};
}
return { status: "success", data: { message: routedUrlData.props.message ?? "" }, redirect: false };
}
return { status: "success", data: { message: "No Route nor custom message found." }, redirect: false };
}
private async handleRedirect(destination: string): Promise<ApiResponse<unknown> & { redirect: boolean }> {
const routingUrl = new URL(destination);
const routingSearchParams = routingUrl.searchParams;
if (
routingSearchParams.get("cal.action") === "eventTypeRedirectUrl" &&
routingSearchParams.has("email") &&
routingSearchParams.has("cal.teamId") &&
!routingSearchParams.has("cal.skipContactOwner")
) {
return this.handleRedirectWithContactOwner(routingUrl, routingSearchParams);
}
console.log("handleRedirect Regular called", { destination });
return { status: "success", data: destination, redirect: true };
}
private async handleRedirectWithContactOwner(
routingUrl: URL,
routingSearchParams: URLSearchParams
): Promise<ApiResponse<unknown> & { redirect: boolean }> {
console.log("handleRedirectWithContactOwner called", { routingUrl, routingSearchParams });
const pathNameParams = routingUrl.pathname.split("/");
const eventTypeSlug = pathNameParams[pathNameParams.length - 1];
const teamId = Number(routingSearchParams.get("cal.teamId"));
const eventTypeData = await this.teamsEventTypesRepository.getTeamEventTypeBySlug(
teamId,
eventTypeSlug,
3
);
if (!eventTypeData) {
throw new NotFoundException("Event type not found.");
}
// get the salesforce record owner email for the email given as a form response.
const {
email: teamMemberEmail,
recordType: crmOwnerRecordType,
crmAppSlug,
} = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: Object.fromEntries(routingSearchParams),
eventData: eventTypeData,
});
teamMemberEmail && routingUrl.searchParams.set("cal.teamMemberEmail", teamMemberEmail);
crmOwnerRecordType && routingUrl.searchParams.set("cal.crmOwnerRecordType", crmOwnerRecordType);
crmAppSlug && routingUrl.searchParams.set("cal.crmAppSlug", crmAppSlug);
return { status: "success", data: routingUrl.toString(), redirect: true };
}
}