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>
This commit is contained in:
sean-brydon
2025-07-09 09:26:01 +01:00
committed by GitHub
co-authored by sean@cal.com <Sean@brydon.io> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
parent 6410687fb6
commit 19563aa697
36 changed files with 982 additions and 263 deletions
@@ -43,7 +43,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
teamId: number | null;
userId: number | null;
title: string;
}
};
let membershipsRepositoryFixture: MembershipRepositoryFixture;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
@@ -130,24 +130,24 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"]
}
}
}
valueType: ["text"],
},
},
},
},
action: {
type: "eventTypeRedirectUrl",
eventTypeId: routingEventType.id,
value: `team/${team.slug}/${routingEventType.slug}`
value: `team/${team.slug}/${routingEventType.slug}`,
},
isFallback: false
isFallback: false,
},
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" }
}
queryValue: { id: "fallback-route", type: "group" },
},
],
fields: [
{
@@ -155,18 +155,18 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
type: "text",
label: "Question 1",
required: true,
identifier: "question1"
identifier: "question1",
},
{
id: "question2",
id: "question2",
type: "text",
label: "Question 2",
required: false,
identifier: "question2"
}
identifier: "question2",
},
],
settings: {
emailOwnerOnSubmission: false
emailOwnerOnSubmission: false,
},
teamId: team.id,
userId: user.id,
@@ -245,7 +245,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
describe(`POST /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => {
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`)
.post(
`/v2/organizations/99999/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
@@ -255,7 +257,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should return 404 when routing form does not exist", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses?start=2050-09-05&end=2050-09-06`)
.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}` })
.send({
question1: "answer1",
@@ -265,7 +269,9 @@ 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`)
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.send({
question1: "answer1",
})
@@ -274,7 +280,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`)
.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
@@ -296,7 +304,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`)
.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({
question2: "answer2", // Missing required question1
@@ -306,7 +316,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`)
.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: "different-answer", // This won't match any route
@@ -334,7 +346,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
})
.expect(400);
// Missing end parameter
// 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}` })
@@ -347,7 +359,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`)
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=invalid-date&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
@@ -357,7 +371,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`)
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-10&end=2050-09-05`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
@@ -371,10 +387,15 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
email: `unauthorized-user-${randomString()}@api.com`,
});
const { keyString: unauthorizedApiKey } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
const { keyString: unauthorizedApiKey } = await apiKeysRepositoryFixture.createApiKey(
unauthorizedUser.id,
null
);
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${unauthorizedApiKey}` })
.send({
question1: "answer1",
@@ -390,7 +411,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`)
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06&queueResponse=true`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
@@ -426,24 +449,24 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"]
}
}
}
valueType: ["text"],
},
},
},
},
action: {
type: "eventTypeRedirectUrl",
eventTypeId: 99999, // Invalid event type ID
value: `team/${team.slug}/non-existent-event-type`
value: `team/${team.slug}/non-existent-event-type`,
},
isFallback: false
isFallback: false,
},
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" }
}
queryValue: { id: "fallback-route", type: "group" },
},
],
fields: [
{
@@ -451,11 +474,11 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
type: "text",
label: "Question 1",
required: true,
identifier: "question1"
}
identifier: "question1",
},
],
settings: {
emailOwnerOnSubmission: false
emailOwnerOnSubmission: false,
},
teamId: team.id,
userId: user.id,
@@ -464,7 +487,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
// 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`)
.post(
`/v2/organizations/${org.id}/routing-forms/${routingFormWithInvalidEventType.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
@@ -474,7 +499,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
// Clean up the form
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
where: { id: routingFormWithInvalidEventType.id }
where: { id: routingFormWithInvalidEventType.id },
});
});
@@ -482,7 +507,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
// 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`)
.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
@@ -524,23 +551,23 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
operator: "equal",
value: ["external"],
valueSrc: ["value"],
valueType: ["text"]
}
}
}
valueType: ["text"],
},
},
},
},
action: {
type: "externalRedirectUrl",
value: "https://example.com/external-booking"
value: "https://example.com/external-booking",
},
isFallback: false
isFallback: false,
},
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" }
}
queryValue: { id: "fallback-route", type: "group" },
},
],
fields: [
{
@@ -548,11 +575,11 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
type: "text",
label: "Question 1",
required: true,
identifier: "question1"
}
identifier: "question1",
},
],
settings: {
emailOwnerOnSubmission: false
emailOwnerOnSubmission: false,
},
teamId: team.id,
userId: user.id,
@@ -560,7 +587,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
});
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${externalRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
.post(
`/v2/organizations/${org.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
@@ -574,7 +603,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
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();
@@ -583,10 +612,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
// Clean up the external routing form
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
where: { id: externalRoutingForm.id }
where: { id: externalRoutingForm.id },
});
});
});
describe(`PATCH /v2/organizations/:orgId/routing-forms/:routingFormId/responses/:responseId`, () => {
@@ -6,9 +6,9 @@ import { GetAvailableSlotsInput_2024_09_04 } from "@calcom/platform-types";
export class CreateRoutingFormResponseInput extends GetAvailableSlotsInput_2024_09_04 {
@Transform(({ value }: { value: string | boolean }) => {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
return value.toLowerCase() === 'true';
if (typeof value === "boolean") return value;
if (typeof value === "string") {
return value.toLowerCase() === "true";
}
return undefined;
})
@@ -20,4 +20,4 @@ export class CreateRoutingFormResponseInput extends GetAvailableSlotsInput_2024_
example: true,
})
queueResponse?: boolean;
}
}
@@ -2,7 +2,12 @@ import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger
import { Type } from "class-transformer";
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
import { ApiResponseWithoutData, SlotsOutput_2024_09_04, RangeSlotsOutput_2024_09_04 } from "@calcom/platform-types";
import {
ApiResponseWithoutData,
SlotsOutput_2024_09_04,
RangeSlotsOutput_2024_09_04,
} from "@calcom/platform-types";
class Routing {
@ApiProperty({
type: String,
@@ -133,4 +138,4 @@ export class CreateRoutingFormResponseOutput extends ApiResponseWithoutData {
@ApiProperty({ type: CreateRoutingFormResponseOutputData })
@Type(() => CreateRoutingFormResponseOutputData)
data!: CreateRoutingFormResponseOutputData;
}
}
@@ -2,7 +2,12 @@ import { OrganizationsRoutingFormsRepository } from "@/modules/organizations/rou
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 {
BadRequestException,
Injectable,
NotFoundException,
InternalServerErrorException,
} from "@nestjs/common";
import { Request } from "express";
import { getRoutedUrl } from "@calcom/platform-libraries";
@@ -71,10 +76,14 @@ export class OrganizationsRoutingFormsResponsesService {
request: Request
): Promise<CreateRoutingFormResponseOutputData> {
const { queueResponse, ...slotsQuery } = query;
this.validateDateRange(slotsQuery.start, slotsQuery.end);
const { redirectUrl, customMessage } = await this.getRoutingUrl(request, routingFormId, queueResponse ?? false);
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) {
@@ -86,7 +95,7 @@ export class OrganizationsRoutingFormsResponsesService {
if (!this.isEventTypeRedirectUrl(redirectUrl)) {
return {
routingExternalRedirectUrl: redirectUrl.toString(),
}
};
}
// Extract event type information from the routed URL
@@ -123,9 +132,11 @@ export class OrganizationsRoutingFormsResponsesService {
slots,
};
}
if (!queuedResponseId) {
throw new InternalServerErrorException("No routing form response ID or queued form response ID could be found.");
throw new InternalServerErrorException(
"No routing form response ID or queued form response ID could be found."
);
}
return {
@@ -145,7 +156,7 @@ export class OrganizationsRoutingFormsResponsesService {
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.");
}
@@ -153,10 +164,13 @@ export class OrganizationsRoutingFormsResponsesService {
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);
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.");
@@ -186,8 +200,6 @@ export class OrganizationsRoutingFormsResponsesService {
}
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);
@@ -20,7 +20,14 @@ import { OrganizationsTeamsRoutingFormsResponsesService } from "./services/organ
import { OrganizationsTeamsRoutingFormsService } from "./services/organizations-teams-routing-forms.service";
@Module({
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule, SlotsModule_2024_09_04, TeamsEventTypesModule],
imports: [
PrismaModule,
StripeModule,
RedisModule,
RoutingFormsModule,
SlotsModule_2024_09_04,
TeamsEventTypesModule,
],
providers: [
OrganizationsTeamsRoutingFormsService,
OrganizationsTeamsRoutingFormsResponsesService,
@@ -25,7 +25,6 @@ export class RouterController {
@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) {