feat: add redirect option for non-routed visits to event types (#27468)
* feat: add redirect option for non-routing form bookings Add a new event type option that redirects to a custom URL when the booking was not made through a routing form (no cal.routingFormResponseId or cal.queuedFormResponseId query parameters). Changes: - Add redirectUrlOnNoRoutingFormResponse field to EventType schema - Add UI toggle in Advanced tab to configure the redirect URL - Implement redirect logic in bookingSuccessRedirect hook - Add translations for new UI strings Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: add redirectUrlOnNoRoutingFormResponse to test builder Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: add redirectUrlOnNoRoutingFormResponse to BookerEvent type Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: add redirectUrlOnNoRoutingFormResponse to getPublicEventSelect Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: add redirect logic to getServerSideProps for non-routing form bookings Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: remove redirectUrlOnNoRoutingFormResponse from booking success flow Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: use 'in' operator for type narrowing on redirectUrlOnNoRoutingFormResponse Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: exclude redirectUrlOnNoRoutingFormResponse from test assertions Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: include redirectUrlOnNoRoutingFormResponse in test assertions and update description Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: add redirectUrlOnNoRoutingFormResponse to mockUpdatedEventType Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: remove redirect logic from instant meetings Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: add redirectUrlOnNoRoutingFormResponse to EventTypeRepository select Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: add redirectUrlOnNoRoutingFormResponse to form default values Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Reword string * fix: bust tRPC cache for redirectUrlOnNoRoutingFormResponse Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: guard redirect when rescheduleUid or bookingUid is present Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: add redirectUrlOnNoRoutingFormResponse to dynamicEvent defaults Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: add bookingUid guard to team getServerSideProps redirect Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
17 files changed
+159
-36
No files matched your search
@@ -62,14 +62,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
session?.user?.id
|
||||
);
|
||||
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
return {
|
||||
props: {
|
||||
eventData,
|
||||
entity: eventData.entity,
|
||||
eventTypeId: eventData.id,
|
||||
|
||||
@@ -30,8 +30,8 @@ function hasApiV2RouteInEnv() {
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { req, params, query } = context;
|
||||
const session = await getServerSession({ req });
|
||||
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params);
|
||||
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting } = query;
|
||||
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params);
|
||||
const { rescheduleUid, bookingUid, isInstantMeeting: queryIsInstantMeeting } = query;
|
||||
const allowRescheduleForCancelledBooking = query.allowRescheduleForCancelledBooking === "true";
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug);
|
||||
|
||||
@@ -55,11 +55,23 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
const eventData = team.eventTypes[0];
|
||||
|
||||
if (eventData.schedulingType === SchedulingType.MANAGED) {
|
||||
return { notFound: true } as const;
|
||||
}
|
||||
if (eventData.schedulingType === SchedulingType.MANAGED) {
|
||||
return { notFound: true } as const;
|
||||
}
|
||||
|
||||
if (rescheduleUid && eventData.disableRescheduling) {
|
||||
// Redirect if no routing form response and redirect URL is configured
|
||||
// Don't redirect if this is a reschedule flow or seated booking flow
|
||||
const hasRoutingFormResponse = query["cal.routingFormResponseId"] || query["cal.queuedFormResponseId"];
|
||||
if (!hasRoutingFormResponse && !rescheduleUid && !bookingUid && eventData.redirectUrlOnNoRoutingFormResponse) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: eventData.redirectUrlOnNoRoutingFormResponse,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (rescheduleUid && eventData.disableRescheduling) {
|
||||
return { redirect: { destination: `/booking/${rescheduleUid}`, permanent: false } };
|
||||
}
|
||||
|
||||
@@ -239,19 +251,20 @@ const getTeamWithEventsData = async (
|
||||
where: {
|
||||
slug: meetingSlug,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
isInstantEvent: true,
|
||||
schedulingType: true,
|
||||
metadata: true,
|
||||
length: true,
|
||||
hidden: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
allowReschedulingCancelledBookings: true,
|
||||
interfaceLanguage: true,
|
||||
hosts: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
isInstantEvent: true,
|
||||
schedulingType: true,
|
||||
metadata: true,
|
||||
length: true,
|
||||
hidden: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
allowReschedulingCancelledBookings: true,
|
||||
redirectUrlOnNoRoutingFormResponse: true,
|
||||
interfaceLanguage: true,
|
||||
hosts: {
|
||||
take: 3,
|
||||
select: {
|
||||
user: {
|
||||
|
||||
@@ -445,6 +445,9 @@ export const EventAdvancedTab = ({
|
||||
setInterfaceLanguageVisible(watchedInterfaceLanguage !== null && watchedInterfaceLanguage !== undefined);
|
||||
}, [watchedInterfaceLanguage]);
|
||||
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!formMethods.getValues("successRedirectUrl"));
|
||||
const [noRoutingFormRedirectUrlVisible, setNoRoutingFormRedirectUrlVisible] = useState(
|
||||
!!formMethods.getValues("redirectUrlOnNoRoutingFormResponse")
|
||||
);
|
||||
|
||||
const bookingFields: Prisma.JsonObject = {};
|
||||
const workflows = eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow);
|
||||
@@ -949,6 +952,51 @@ export const EventAdvancedTab = ({
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="redirectUrlOnNoRoutingFormResponse"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<SettingsToggle
|
||||
labelClassName={classNames("text-sm", customClassNames?.bookingRedirect?.label)}
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName={classNames(
|
||||
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
|
||||
noRoutingFormRedirectUrlVisible && "rounded-b-none",
|
||||
customClassNames?.bookingRedirect?.container
|
||||
)}
|
||||
childrenClassName={classNames("lg:ml-0", customClassNames?.bookingRedirect?.children)}
|
||||
descriptionClassName={customClassNames?.bookingRedirect?.description}
|
||||
title={t("redirect_on_no_routing_form")}
|
||||
data-testid="redirect-on-no-routing-form"
|
||||
{...successRedirectUrlLocked}
|
||||
description={t("redirect_on_no_routing_form_description")}
|
||||
checked={noRoutingFormRedirectUrlVisible}
|
||||
onCheckedChange={(e) => {
|
||||
setNoRoutingFormRedirectUrlVisible(e);
|
||||
onChange(e ? value : "");
|
||||
}}>
|
||||
<div
|
||||
className={classNames(
|
||||
"border-subtle rounded-b-lg border border-t-0 p-6",
|
||||
customClassNames?.bookingRedirect?.redirectUrlInput?.container
|
||||
)}>
|
||||
<TextField
|
||||
className={classNames("w-full", customClassNames?.bookingRedirect?.redirectUrlInput?.input)}
|
||||
label={t("redirect_on_no_routing_form")}
|
||||
labelClassName={customClassNames?.bookingRedirect?.redirectUrlInput?.label}
|
||||
labelSrOnly
|
||||
disabled={successRedirectUrlLocked.disabled}
|
||||
placeholder={t("external_redirect_url")}
|
||||
data-testid="no-routing-form-redirect-url"
|
||||
required={noRoutingFormRedirectUrlVisible}
|
||||
type="text"
|
||||
{...formMethods.register("redirectUrlOnNoRoutingFormResponse")}
|
||||
/>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{!isPlatform && (
|
||||
<Controller
|
||||
name="multiplePrivateLinks"
|
||||
|
||||
@@ -1435,6 +1435,8 @@
|
||||
"you_are_being_redirected": "You are being redirected to {{ url }} in $t(second, {\"count\": {{seconds}} }).",
|
||||
"external_redirect_url": "https://example.com/redirect-to-my-success-page",
|
||||
"redirect_url_description": "Redirect to a custom URL after a successful booking",
|
||||
"redirect_on_no_routing_form": "Redirect when accessed without routing form",
|
||||
"redirect_on_no_routing_form_description": "Redirect to set URL if the event type was not routed to via a routing form",
|
||||
"duplicate": "Duplicate",
|
||||
"offer_seats": "Offer seats",
|
||||
"offer_seats_description": "Offer seats for booking. This automatically disables guest & opt-in bookings. <0>Learn more</0>",
|
||||
|
||||
@@ -168,6 +168,19 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
// Redirect if no routing form response and redirect URL is configured
|
||||
// Don't redirect if this is a reschedule or seated booking flow
|
||||
const hasRoutingFormResponse =
|
||||
context.query["cal.routingFormResponseId"] || context.query["cal.queuedFormResponseId"];
|
||||
if (!hasRoutingFormResponse && !rescheduleUid && !bookingUid && "redirectUrlOnNoRoutingFormResponse" in eventData && eventData.redirectUrlOnNoRoutingFormResponse) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: eventData.redirectUrlOnNoRoutingFormResponse,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const props: Props = {
|
||||
eventData: {
|
||||
...eventData,
|
||||
@@ -254,13 +267,26 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
session?.user?.id
|
||||
);
|
||||
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const allowSEOIndexing = org
|
||||
// Redirect if no routing form response and redirect URL is configured
|
||||
// Don't redirect if this is a reschedule or seated booking flow
|
||||
const hasRoutingFormResponse =
|
||||
context.query["cal.routingFormResponseId"] || context.query["cal.queuedFormResponseId"];
|
||||
if (!hasRoutingFormResponse && !rescheduleUid && !bookingUid && "redirectUrlOnNoRoutingFormResponse" in eventData && eventData.redirectUrlOnNoRoutingFormResponse) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: eventData.redirectUrlOnNoRoutingFormResponse,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const allowSEOIndexing= org
|
||||
? user?.profile?.organization?.organizationSettings?.allowSEOIndexing
|
||||
? user?.allowSEOIndexing
|
||||
: false
|
||||
|
||||
@@ -144,6 +144,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
enablePerHostLocations,
|
||||
redirectUrlOnNoRoutingFormResponse,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
id: 123,
|
||||
@@ -171,6 +172,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
enablePerHostLocations,
|
||||
redirectUrlOnNoRoutingFormResponse,
|
||||
};
|
||||
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
|
||||
|
||||
@@ -233,6 +235,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
includeNoShowInRRCalculation,
|
||||
instantMeetingScheduleId,
|
||||
enablePerHostLocations,
|
||||
redirectUrlOnNoRoutingFormResponse,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
@@ -360,6 +363,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
enablePerHostLocations,
|
||||
redirectUrlOnNoRoutingFormResponse,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
id: 123,
|
||||
@@ -388,6 +392,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
enablePerHostLocations,
|
||||
redirectUrlOnNoRoutingFormResponse,
|
||||
};
|
||||
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
|
||||
|
||||
@@ -452,6 +457,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
rrSegmentQueryValue,
|
||||
useEventLevelSelectedCalendars,
|
||||
enablePerHostLocations,
|
||||
redirectUrlOnNoRoutingFormResponse,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
@@ -527,6 +533,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
enablePerHostLocations,
|
||||
redirectUrlOnNoRoutingFormResponse,
|
||||
...evType
|
||||
} = mockFindFirstEventType({
|
||||
metadata: { managedEventConfig: {} },
|
||||
@@ -562,6 +569,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
instantMeetingScheduleId,
|
||||
assignRRMembersUsingSegment,
|
||||
enablePerHostLocations,
|
||||
redirectUrlOnNoRoutingFormResponse,
|
||||
};
|
||||
prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]);
|
||||
|
||||
@@ -588,6 +596,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
assignRRMembersUsingSegment: false,
|
||||
includeNoShowInRRCalculation: false,
|
||||
enablePerHostLocations,
|
||||
redirectUrlOnNoRoutingFormResponse,
|
||||
...evType,
|
||||
};
|
||||
|
||||
|
||||
@@ -189,9 +189,11 @@ export const useBookingSuccessRedirect = () => {
|
||||
query,
|
||||
booking,
|
||||
forwardParamsSuccessRedirect,
|
||||
redirectUrlOnNoRoutingFormResponse,
|
||||
}: {
|
||||
successRedirectUrl: EventType["successRedirectUrl"];
|
||||
forwardParamsSuccessRedirect: EventType["forwardParamsSuccessRedirect"];
|
||||
redirectUrlOnNoRoutingFormResponse?: EventType["redirectUrlOnNoRoutingFormResponse"];
|
||||
query: Record<string, string | null | undefined | boolean>;
|
||||
booking: SuccessRedirectBookingType;
|
||||
}) => {
|
||||
@@ -201,6 +203,16 @@ export const useBookingSuccessRedirect = () => {
|
||||
"cal.rerouting": searchParams.get("cal.rerouting"),
|
||||
};
|
||||
|
||||
// Check if there's no routing form response and redirect URL is configured
|
||||
const hasRoutingFormResponse =
|
||||
searchParams.get("cal.routingFormResponseId") || searchParams.get("cal.queuedFormResponseId");
|
||||
|
||||
if (!hasRoutingFormResponse && redirectUrlOnNoRoutingFormResponse) {
|
||||
const url = new URL(redirectUrlOnNoRoutingFormResponse);
|
||||
navigateInTopWindow(url.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
if (successRedirectUrl) {
|
||||
const url = new URL(successRedirectUrl);
|
||||
// Using parent ensures, Embed iframe would redirect outside of the iframe.
|
||||
|
||||
@@ -155,6 +155,7 @@ const commons = {
|
||||
updatedAt: null,
|
||||
rrHostSubsetEnabled: false,
|
||||
enablePerHostLocations: false,
|
||||
redirectUrlOnNoRoutingFormResponse: null,
|
||||
};
|
||||
|
||||
export const dynamicEvent = {
|
||||
|
||||
@@ -129,9 +129,10 @@ export const getPublicEventSelect = (fetchAllUsers: boolean) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
successRedirectUrl: true,
|
||||
forwardParamsSuccessRedirect: true,
|
||||
workflows: {
|
||||
successRedirectUrl: true,
|
||||
forwardParamsSuccessRedirect: true,
|
||||
redirectUrlOnNoRoutingFormResponse: true,
|
||||
workflows: {
|
||||
include: {
|
||||
workflow: {
|
||||
include: {
|
||||
|
||||
@@ -160,6 +160,7 @@ export type FormValues = {
|
||||
externalId: string;
|
||||
};
|
||||
successRedirectUrl: string;
|
||||
redirectUrlOnNoRoutingFormResponse: string;
|
||||
durationLimits?: IntervalLimit;
|
||||
bookingLimits?: IntervalLimit;
|
||||
onlyShowFirstAvailableSlot: boolean;
|
||||
|
||||
@@ -622,6 +622,7 @@ export class EventTypeRepository implements IEventTypesRepository {
|
||||
isRRWeightsEnabled: true,
|
||||
rescheduleWithSameRoundRobinHost: true,
|
||||
successRedirectUrl: true,
|
||||
redirectUrlOnNoRoutingFormResponse: true,
|
||||
forwardParamsSuccessRedirect: true,
|
||||
currency: true,
|
||||
bookingFields: true,
|
||||
@@ -937,6 +938,7 @@ export class EventTypeRepository implements IEventTypesRepository {
|
||||
isRRWeightsEnabled: true,
|
||||
rescheduleWithSameRoundRobinHost: true,
|
||||
successRedirectUrl: true,
|
||||
redirectUrlOnNoRoutingFormResponse: true,
|
||||
forwardParamsSuccessRedirect: true,
|
||||
currency: true,
|
||||
bookingFields: true,
|
||||
|
||||
@@ -147,6 +147,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
|
||||
metadata: null,
|
||||
successRedirectUrl: null,
|
||||
forwardParamsSuccessRedirect: true,
|
||||
redirectUrlOnNoRoutingFormResponse: null,
|
||||
bookingFields: [],
|
||||
parentId: null,
|
||||
profileId: null,
|
||||
|
||||
@@ -103,6 +103,7 @@ export const useEventTypeForm = ({
|
||||
hosts: eventType.hosts.sort((a, b) => sortHosts(a, b, eventType.isRRWeightsEnabled)),
|
||||
hostGroups: eventType.hostGroups || [],
|
||||
successRedirectUrl: eventType.successRedirectUrl || "",
|
||||
redirectUrlOnNoRoutingFormResponse: eventType.redirectUrlOnNoRoutingFormResponse || "",
|
||||
forwardParamsSuccessRedirect: eventType.forwardParamsSuccessRedirect,
|
||||
users: eventType.users,
|
||||
useEventTypeDestinationCalendarEmail: eventType.useEventTypeDestinationCalendarEmail,
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."EventType" ADD COLUMN "redirectUrlOnNoRoutingFormResponse" TEXT;
|
||||
@@ -235,6 +235,8 @@ model EventType {
|
||||
/// @zod.import(["import { successRedirectUrl } from '../../zod-utils'"]).custom.use(successRedirectUrl)
|
||||
successRedirectUrl String?
|
||||
forwardParamsSuccessRedirect Boolean? @default(true)
|
||||
/// @zod.import(["import { successRedirectUrl } from '../../zod-utils'"]).custom.use(successRedirectUrl)
|
||||
redirectUrlOnNoRoutingFormResponse String?
|
||||
workflows WorkflowsOnEventTypes[]
|
||||
/// @zod.import(["import { intervalLimitsType } from '../../zod-utils'"]).custom.use(intervalLimitsType)
|
||||
bookingLimits Json?
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Cache bust: workflows.activeOn.eventType no longer includes _count.children
|
||||
// Cache bust: added redirectUrlOnNoRoutingFormResponse to eventType
|
||||
import getEventTypeById from "@calcom/features/eventtypes/lib/getEventTypeById";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
|
||||
@@ -204,6 +204,7 @@ export type TUpdateInputSchema = {
|
||||
metadata?: EventTypeMetadata;
|
||||
successRedirectUrl?: string | null;
|
||||
forwardParamsSuccessRedirect?: boolean | null;
|
||||
redirectUrlOnNoRoutingFormResponse?: string | null;
|
||||
bookingLimits?: IntervalLimit | null;
|
||||
durationLimits?: IntervalLimit | null;
|
||||
isInstantEvent?: boolean;
|
||||
@@ -401,6 +402,7 @@ const BaseEventTypeUpdateInput: z.ZodType<TUpdateInputSchema> = z
|
||||
metadata: EventTypeMetaDataSchema.optional(),
|
||||
successRedirectUrl: z.string().nullable().optional(),
|
||||
forwardParamsSuccessRedirect: z.boolean().nullable().optional(),
|
||||
redirectUrlOnNoRoutingFormResponse: z.string().nullable().optional(),
|
||||
bookingLimits: intervalLimitsType.nullable().optional(),
|
||||
durationLimits: intervalLimitsType.nullable().optional(),
|
||||
isInstantEvent: z.boolean().optional(),
|
||||
|
||||
Reference in new issue
Block a user