* refactor: decouple @calcom/prisma from @calcom/features, @calcom/ee, and @calcom/lib - Inline helper functions in zod-utils.ts (emailSchema, slugify, getValidRhfFieldName, isPasswordValid, intervalLimitsType, zodAttributesQueryValue) - Update schema.prisma @zod.import comments to reference zod-utils instead of @calcom/lib - Inline idempotency key generation in booking-idempotency-key extension using uuid v5 - Move usage-tracking extension to @calcom/ee/prisma-extensions/ - Remove usage-tracking extension from packages/prisma/index.ts - Move Prisma DI module from @calcom/prisma to @calcom/features/di/modules/Prisma.ts - Update 20 import paths in @calcom/features to use new Prisma DI module - Remove @calcom/lib dependency from @calcom/prisma package.json - Add uuid dependency to @calcom/prisma package.json This reduces the dependency footprint of @calcom/prisma and breaks circular dependencies between packages. Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * feat: add EE-specific Prisma DI module with usage tracking Create packages/ee/di/modules/PrismaEE.ts to apply the usage-tracking extension in EE contexts. This module: - Imports the base prisma client from @calcom/prisma - Applies the usageTrackingExtention from @calcom/ee/prisma-extensions/usage-tracking - Exports the same DI tokens as the base Prisma module for override in EE containers This ensures usage tracking functionality is preserved in EE builds while keeping the base @calcom/prisma package free of EE dependencies. Note: EE containers should load this module after the base Prisma module to override the bindings. Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1123 lines
35 KiB
TypeScript
1123 lines
35 KiB
TypeScript
import type { UnitTypeLongPlural } from "dayjs";
|
|
import type { TFunction } from "i18next";
|
|
import z, { ZodNullable, ZodObject, ZodOptional } from "zod";
|
|
import type {
|
|
AnyZodObject,
|
|
objectInputType,
|
|
objectOutputType,
|
|
ZodNullableDef,
|
|
ZodOptionalDef,
|
|
ZodRawShape,
|
|
ZodTypeAny,
|
|
} from "zod";
|
|
|
|
import { EventTypeCustomInputType } from "@calcom/prisma/enums";
|
|
|
|
import type { Prisma } from "./client";
|
|
|
|
|
|
/** @see https://github.com/colinhacks/zod/issues/3155#issuecomment-2060045794 */
|
|
export const emailRegex =
|
|
/* eslint-disable-next-line no-useless-escape */
|
|
/^(?!\.)(?!.*\.\.)([A-Z0-9_+-\.']*)[A-Z0-9_+'-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i;
|
|
|
|
/**
|
|
* RFC 5321 Section 4.5.3.1.3 specifies:
|
|
* - Maximum email address length: 254 characters
|
|
* - Local part (before @): max 64 characters
|
|
* - Domain part (after @): max 253 characters
|
|
*/
|
|
const MAX_EMAIL_LENGTH = 254;
|
|
|
|
const emailRegexSchema = z
|
|
.string()
|
|
.max(MAX_EMAIL_LENGTH, { message: "Email address is too long" })
|
|
.regex(emailRegex);
|
|
|
|
const slugify = (str: string, forDisplayingInput?: boolean) => {
|
|
if (!str) {
|
|
return "";
|
|
}
|
|
|
|
const s = str
|
|
.toLowerCase() // Convert to lowercase
|
|
.trim() // Remove whitespace from both sides
|
|
.normalize("NFD") // Normalize to decomposed form for handling accents
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore
|
|
.replace(/\p{Diacritic}/gu, "") // Remove any diacritics (accents) from characters
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore
|
|
.replace(/[^.\p{L}\p{N}\p{Zs}\p{Emoji}]+/gu, "-") // Replace any non-alphanumeric characters (including Unicode and except "." period) with a dash
|
|
.replace(/[\s_#]+/g, "-") // Replace whitespace, # and underscores with a single dash
|
|
.replace(/^-+/, "") // Remove dashes from start
|
|
.replace(/\.{2,}/g, ".") // Replace consecutive periods with a single period
|
|
.replace(/^\.+/, "") // Remove periods from the start
|
|
.replace(
|
|
/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g,
|
|
""
|
|
) // Removes emojis
|
|
.replace(/\s+/g, " ")
|
|
.replace(/-+/g, "-"); // Replace consecutive dashes with a single dash
|
|
|
|
return forDisplayingInput ? s : s.replace(/-+$/, "").replace(/\.*$/, ""); // Remove dashes and period from end
|
|
};
|
|
|
|
const getValidRhfFieldName = (fieldName: string) => {
|
|
// Remember that any transformation that you do here would run on System Field names as well. So, be careful and avoiding doing anything here that would modify the SystemField names.
|
|
// e.g. SystemField name currently have uppercases in them. So, no need to lowercase unless absolutely needed.
|
|
return fieldName.replace(/[^a-zA-Z0-9-_]/g, "-");
|
|
};
|
|
|
|
function isPasswordValid(password: string): boolean;
|
|
function isPasswordValid(
|
|
password: string,
|
|
breakdown: boolean,
|
|
strict?: boolean
|
|
): { caplow: boolean; num: boolean; min: boolean; admin_min: boolean };
|
|
function isPasswordValid(password: string, breakdown?: boolean, strict?: boolean) {
|
|
let cap = false, // Has uppercase characters
|
|
low = false, // Has lowercase characters
|
|
num = false, // At least one number
|
|
min = false, // Eight characters, or fifteen in strict mode.
|
|
admin_min = false;
|
|
if (password.length >= 7 && (!strict || password.length > 14)) min = true;
|
|
if (strict && password.length > 14) admin_min = true;
|
|
if (password.match(/\d/)) num = true;
|
|
if (password.match(/[a-z]/)) low = true;
|
|
if (password.match(/[A-Z]/)) cap = true;
|
|
|
|
if (!breakdown) return cap && low && num && min && (strict ? admin_min : true);
|
|
|
|
let errors: Record<string, boolean> = { caplow: cap && low, num, min };
|
|
// Only return the admin key if strict mode is enabled.
|
|
if (strict) errors = { ...errors, admin_min };
|
|
|
|
return errors;
|
|
}
|
|
|
|
export type IntervalLimitUnit = "day" | "week" | "month" | "year";
|
|
export type IntervalLimit = Partial<Record<`PER_${Uppercase<IntervalLimitUnit>}`, number | undefined>>;
|
|
export type IntervalLimitKey = keyof IntervalLimit;
|
|
|
|
export const intervalLimitsType: z.Schema<IntervalLimit | null> = z
|
|
.object({
|
|
PER_DAY: z.number().optional(),
|
|
PER_WEEK: z.number().optional(),
|
|
PER_MONTH: z.number().optional(),
|
|
PER_YEAR: z.number().optional(),
|
|
})
|
|
.nullable();
|
|
|
|
const raqbChildSchema = z.object({
|
|
type: z.string().optional(),
|
|
properties: z
|
|
.object({
|
|
field: z.any().optional(),
|
|
operator: z.any().optional(),
|
|
value: z.any().optional(),
|
|
valueSrc: z.any().optional(),
|
|
valueError: z.array(z.union([z.string(), z.null()])).optional(),
|
|
valueType: z.any().optional(),
|
|
})
|
|
.optional(),
|
|
});
|
|
|
|
const raqbChildren1Schema = z
|
|
.record(raqbChildSchema)
|
|
.superRefine((children1, ctx) => {
|
|
if (!children1) return;
|
|
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
typeof value === "object" && value !== null;
|
|
Object.entries(children1).forEach(([, _rule]) => {
|
|
const rule = _rule as unknown;
|
|
if (!isObject(rule) || rule.type !== "rule") return;
|
|
if (!isObject(rule.properties)) return;
|
|
|
|
const value = rule.properties.value || [];
|
|
const valueSrc = rule.properties.valueSrc;
|
|
if (!(value instanceof Array) || !(valueSrc instanceof Array)) {
|
|
return;
|
|
}
|
|
|
|
if (!valueSrc.length) {
|
|
// If valueSrc is empty, value could be empty for operators like is_empty, is_not_empty
|
|
return;
|
|
}
|
|
|
|
// MultiSelect array can be 2D array
|
|
const flattenedValues = value.flat();
|
|
|
|
const validValues = flattenedValues.filter((value: unknown) => {
|
|
// Might want to restrict it to filter out null and empty string as well. But for now we know that Prisma errors only for undefined values when saving it in JSON field
|
|
// Also, it is possible that RAQB has some requirements to support null or empty string values.
|
|
if (value === undefined) return false;
|
|
return true;
|
|
});
|
|
|
|
if (!validValues.length) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Looks like you are trying to create a rule with no value",
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
const raqbQueryValueSchema = z.union([
|
|
z.object({
|
|
id: z.string().optional(),
|
|
type: z.literal("group"),
|
|
children1: raqbChildren1Schema.optional(),
|
|
properties: z.any(),
|
|
}),
|
|
z.object({
|
|
id: z.string().optional(),
|
|
type: z.literal("switch_group"),
|
|
children1: raqbChildren1Schema.optional(),
|
|
properties: z.any(),
|
|
}),
|
|
]);
|
|
|
|
const zodAttributesQueryValue = raqbQueryValueSchema;
|
|
|
|
|
|
// Let's not import 118kb just to get an enum
|
|
export enum Frequency {
|
|
YEARLY = 0,
|
|
MONTHLY = 1,
|
|
WEEKLY = 2,
|
|
DAILY = 3,
|
|
HOURLY = 4,
|
|
MINUTELY = 5,
|
|
SECONDLY = 6,
|
|
}
|
|
|
|
export enum BookerLayouts {
|
|
MONTH_VIEW = "month_view",
|
|
WEEK_VIEW = "week_view",
|
|
COLUMN_VIEW = "column_view",
|
|
}
|
|
|
|
export const bookerLayoutOptions = [
|
|
BookerLayouts.MONTH_VIEW,
|
|
BookerLayouts.WEEK_VIEW,
|
|
BookerLayouts.COLUMN_VIEW,
|
|
];
|
|
|
|
const layoutOptions = z.union([
|
|
z.literal(bookerLayoutOptions[0]),
|
|
z.literal(bookerLayoutOptions[1]),
|
|
z.literal(bookerLayoutOptions[2]),
|
|
]);
|
|
|
|
export const bookerLayouts = z
|
|
.object({
|
|
enabledLayouts: z.array(layoutOptions),
|
|
defaultLayout: layoutOptions,
|
|
})
|
|
.nullable();
|
|
|
|
export const orgOnboardingInvitedMembersSchema = z.array(
|
|
z.object({
|
|
email: z.string().email(),
|
|
name: z.string().optional(),
|
|
teamId: z.number().optional(),
|
|
teamName: z.string().optional(),
|
|
role: z.enum(["MEMBER", "ADMIN"]).optional().default("MEMBER"),
|
|
})
|
|
);
|
|
|
|
export const orgOnboardingTeamsSchema = z.array(
|
|
z.object({
|
|
id: z.number(),
|
|
name: z.string(),
|
|
isBeingMigrated: z.boolean(),
|
|
// "slug" is null for new teams
|
|
slug: z.string().nullable(),
|
|
})
|
|
);
|
|
|
|
export const defaultBookerLayoutSettings = {
|
|
defaultLayout: BookerLayouts.MONTH_VIEW,
|
|
// if the user has no explicit layouts set (not in user profile and not in event settings), all layouts are enabled.
|
|
enabledLayouts: bookerLayoutOptions,
|
|
};
|
|
|
|
export type BookerLayoutSettings = z.infer<typeof bookerLayouts>;
|
|
|
|
export const RequiresConfirmationThresholdUnits: z.ZodType<UnitTypeLongPlural> = z.enum(["hours", "minutes"]);
|
|
|
|
const _eventTypeMetaDataSchemaWithoutApps = z.object({
|
|
smartContractAddress: z.string().optional(),
|
|
blockchainId: z.number().optional(),
|
|
multipleDuration: z.number().array().optional(),
|
|
giphyThankYouPage: z.string().optional(),
|
|
additionalNotesRequired: z.boolean().optional(),
|
|
disableSuccessPage: z.boolean().optional(),
|
|
disableStandardEmails: z
|
|
.object({
|
|
all: z
|
|
.object({
|
|
host: z.boolean().optional(),
|
|
attendee: z.boolean().optional(),
|
|
})
|
|
.optional(),
|
|
confirmation: z
|
|
.object({
|
|
host: z.boolean().optional(),
|
|
attendee: z.boolean().optional(),
|
|
})
|
|
.optional(),
|
|
})
|
|
.optional(),
|
|
managedEventConfig: z
|
|
.object({
|
|
unlockedFields: z.custom<{ [k in keyof Omit<Prisma.EventTypeSelect, "id">]: true }>().optional(),
|
|
})
|
|
.optional(),
|
|
requiresConfirmationThreshold: z
|
|
.object({
|
|
time: z.number(),
|
|
unit: RequiresConfirmationThresholdUnits,
|
|
})
|
|
.optional(),
|
|
config: z
|
|
.object({
|
|
useHostSchedulesForTeamEvent: z.boolean().optional(),
|
|
})
|
|
.optional(),
|
|
bookerLayouts: bookerLayouts.optional(),
|
|
});
|
|
|
|
export const eventTypeMetaDataSchemaWithUntypedApps = _eventTypeMetaDataSchemaWithoutApps.merge(
|
|
z.object({
|
|
apps: z.record(z.string(), z.any()).optional(),
|
|
})
|
|
);
|
|
|
|
export const EventTypeMetaDataSchema = eventTypeMetaDataSchemaWithUntypedApps.nullable();
|
|
export const eventTypeMetaDataSchemaWithoutApps = _eventTypeMetaDataSchemaWithoutApps.nullable();
|
|
|
|
export type EventTypeMetadata = z.infer<typeof EventTypeMetaDataSchema>;
|
|
|
|
// Validation of user added bookingFields' responses happen using `getBookingResponsesSchema` which requires `eventType`.
|
|
// So it is a dynamic validation and thus entire validation can't exist here
|
|
// Note that this validation runs to validate prefill params as well, so it should consider that partial values can be there. e.g. `name` might be empty string
|
|
export const bookingResponses = z
|
|
.object({
|
|
email: z.string(),
|
|
attendeePhoneNumber: z.string().optional(),
|
|
//TODO: Why don't we move name out of bookingResponses and let it be handled like user fields?
|
|
name: z.union([
|
|
z.string(),
|
|
z.object({
|
|
firstName: z.string(),
|
|
lastName: z.string().optional(),
|
|
}),
|
|
]),
|
|
guests: z.array(z.string()).optional(),
|
|
notes: z.string().optional(),
|
|
location: z
|
|
.object({
|
|
optionValue: z.string(),
|
|
value: z.string(),
|
|
})
|
|
.optional(),
|
|
smsReminderNumber: z.string().optional(),
|
|
rescheduleReason: z.string().optional(),
|
|
})
|
|
.nullable();
|
|
|
|
export type BookingResponses = z.infer<typeof bookingResponses>;
|
|
|
|
export const eventTypeLocations = z.array(
|
|
z.object({
|
|
// TODO: Couldn't find a way to make it a union of types from App Store locations
|
|
// Creating a dynamic union by iterating over the object doesn't seem to make TS happy
|
|
type: z.string(),
|
|
address: z.string().optional(),
|
|
link: z.string().url().optional(),
|
|
displayLocationPublicly: z.boolean().optional(),
|
|
hostPhoneNumber: z.string().optional(),
|
|
credentialId: z.number().optional(),
|
|
teamName: z.string().optional(),
|
|
customLabel: z.string().optional(),
|
|
})
|
|
);
|
|
|
|
// Matching RRule.Options: rrule/dist/esm/src/types.d.ts
|
|
export const recurringEventType = z
|
|
.object({
|
|
dtstart: z.date().optional(),
|
|
interval: z.number(),
|
|
count: z.number(),
|
|
freq: z.nativeEnum(Frequency),
|
|
until: z.date().optional(),
|
|
tzid: z.string().optional(),
|
|
})
|
|
.nullable();
|
|
|
|
// dayjs iso parsing is very buggy - cant use :( - turns ISO string into Date object
|
|
export const iso8601 = z.string().transform((val, ctx) => {
|
|
const time = Date.parse(val);
|
|
if (!time) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Invalid ISO Date",
|
|
});
|
|
}
|
|
const d = new Date();
|
|
d.setTime(time);
|
|
return d;
|
|
});
|
|
|
|
export const eventTypeColor = z
|
|
.object({
|
|
lightEventTypeColor: z.string(),
|
|
darkEventTypeColor: z.string(),
|
|
})
|
|
.nullable();
|
|
|
|
export type IntervalLimitsType = IntervalLimit | null;
|
|
|
|
export const eventTypeSlug = z
|
|
.string()
|
|
.trim()
|
|
.transform((val) => slugify(val))
|
|
.refine((val) => val.length >= 1, {
|
|
message: "Please enter at least one character",
|
|
});
|
|
|
|
export const stringToDate = z.string().transform((a) => new Date(a));
|
|
|
|
export const stringOrNumber = z.union([
|
|
z.string().transform((v, ctx) => {
|
|
const parsed = parseInt(v);
|
|
if (isNaN(parsed)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Not a number",
|
|
});
|
|
}
|
|
return parsed;
|
|
}),
|
|
z.number().int(),
|
|
]);
|
|
|
|
export const requiredCustomInputSchema = z.union([
|
|
// string must be given & nonempty
|
|
z.string().trim().min(1),
|
|
// boolean must be true if set.
|
|
z.boolean().refine((v) => v === true),
|
|
]);
|
|
|
|
const PlatformClientParamsSchema = z.object({
|
|
platformClientId: z.string().optional(),
|
|
platformRescheduleUrl: z.string().nullable().optional(),
|
|
platformCancelUrl: z.string().nullable().optional(),
|
|
platformBookingUrl: z.string().nullable().optional(),
|
|
platformBookingLocation: z.string().optional(),
|
|
areCalendarEventsEnabled: z.boolean().optional(),
|
|
});
|
|
|
|
export type PlatformClientParams = z.infer<typeof PlatformClientParamsSchema>;
|
|
|
|
export const bookingConfirmPatchBodySchema = z.object({
|
|
bookingId: z.number(),
|
|
confirmed: z.boolean(),
|
|
recurringEventId: z.string().optional(),
|
|
reason: z.string().optional(),
|
|
emailsEnabled: z.boolean().default(true),
|
|
platformClientParams: PlatformClientParamsSchema.optional(),
|
|
});
|
|
|
|
export const bookingCancelSchema = z.object({
|
|
id: z.number().optional(),
|
|
uid: z.string().optional(),
|
|
// note(Lauris): allRemainingBookings will cancel all bookings that have start time greater than this moment.
|
|
allRemainingBookings: z.boolean().optional(),
|
|
// note(Lauris): cancelSubsequentBookings will cancel all bookings after one specified by id or uid.
|
|
cancelSubsequentBookings: z.boolean().optional(),
|
|
cancellationReason: z.string().optional(),
|
|
skipCancellationReasonValidation: z.boolean().optional(),
|
|
seatReferenceUid: z.string().optional(),
|
|
cancelledBy: z.string().email({ message: "Invalid email" }).optional(),
|
|
internalNote: z
|
|
.object({
|
|
id: z.number(),
|
|
name: z.string(),
|
|
cancellationReason: z.string().optional().nullable(),
|
|
})
|
|
.optional()
|
|
.nullable(),
|
|
});
|
|
|
|
export const bookingCancelAttendeeSeatSchema = z.object({
|
|
seatReferenceUid: z.string(),
|
|
});
|
|
|
|
export const bookingCancelInput = bookingCancelSchema.refine(
|
|
(data) => !!data.id || !!data.uid,
|
|
"At least one of the following required: 'id', 'uid'."
|
|
);
|
|
|
|
export const bookingCancelWithCsrfSchema = bookingCancelSchema
|
|
.extend({
|
|
csrfToken: z.string().length(64, "Invalid CSRF token"),
|
|
})
|
|
.refine((data) => !!data.id || !!data.uid, "At least one of the following required: 'id', 'uid'.");
|
|
|
|
export const vitalSettingsUpdateSchema = z.object({
|
|
connected: z.boolean().optional(),
|
|
selectedParam: z.string().optional(),
|
|
sleepValue: z.number().optional(),
|
|
});
|
|
|
|
export const createdEventSchema = z
|
|
.object({
|
|
id: z.string(),
|
|
thirdPartyRecurringEventId: z.string(),
|
|
password: z.union([z.string(), z.undefined()]),
|
|
onlineMeetingUrl: z.string().nullable(),
|
|
iCalUID: z.string().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const schemaDefaultConferencingApp = z.object({
|
|
appSlug: z.string().default("daily-video").optional(),
|
|
appLink: z.string().optional(),
|
|
});
|
|
|
|
export const userMetadata = z
|
|
.object({
|
|
proPaidForByTeamId: z.number().optional(),
|
|
stripeCustomerId: z.string().optional(),
|
|
vitalSettings: vitalSettingsUpdateSchema.optional(),
|
|
isPremium: z.boolean().optional(),
|
|
sessionTimeout: z.number().optional(), // Minutes
|
|
defaultConferencingApp: schemaDefaultConferencingApp.optional(),
|
|
defaultBookerLayouts: bookerLayouts.optional(),
|
|
emailChangeWaitingForVerification: z
|
|
.string()
|
|
.transform((data) => data.toLowerCase())
|
|
.optional(),
|
|
migratedToOrgFrom: z
|
|
.object({
|
|
username: z.string().or(z.null()).optional(),
|
|
lastMigrationTime: z.string().optional(),
|
|
reverted: z.boolean().optional(),
|
|
revertTime: z.string().optional(),
|
|
})
|
|
.optional(),
|
|
})
|
|
.nullable();
|
|
|
|
export type DefaultConferencingApp = z.infer<typeof schemaDefaultConferencingApp>;
|
|
|
|
export const orgSettingsSchema = z
|
|
.object({
|
|
isOrganizationVerified: z.boolean().optional(),
|
|
isOrganizationConfigured: z.boolean().optional(),
|
|
isAdminReviewed: z.boolean().optional(),
|
|
orgAutoAcceptEmail: z.string().optional(),
|
|
isAdminAPIEnabled: z.boolean().optional(),
|
|
})
|
|
.nullable();
|
|
export type userMetadataType = z.infer<typeof userMetadata>;
|
|
|
|
export enum BillingPeriod {
|
|
MONTHLY = "MONTHLY",
|
|
ANNUALLY = "ANNUALLY",
|
|
}
|
|
|
|
const baseTeamMetadataSchema = z.object({
|
|
defaultConferencingApp: schemaDefaultConferencingApp.optional(),
|
|
requestedSlug: z.string().or(z.null()),
|
|
paymentId: z.string(),
|
|
subscriptionId: z.string().nullable(),
|
|
subscriptionItemId: z.string().nullable(),
|
|
orgSeats: z.number().nullable(),
|
|
orgPricePerSeat: z.number().nullable(),
|
|
migratedToOrgFrom: z
|
|
.object({
|
|
teamSlug: z.string().or(z.null()).optional(),
|
|
lastMigrationTime: z.string().optional(),
|
|
reverted: z.boolean().optional(),
|
|
lastRevertTime: z.string().optional(),
|
|
})
|
|
.optional(),
|
|
billingPeriod: z.nativeEnum(BillingPeriod).optional(),
|
|
});
|
|
|
|
export const teamMetadataSchema = baseTeamMetadataSchema.partial().nullable();
|
|
|
|
export const teamMetadataStrictSchema = baseTeamMetadataSchema
|
|
.extend({
|
|
subscriptionId: z
|
|
.string()
|
|
.refine((val) => val.startsWith("sub_"), {
|
|
message: "subscriptionId must start with 'sub_'",
|
|
})
|
|
.nullable(),
|
|
subscriptionItemId: z
|
|
.string()
|
|
.refine((val) => val.startsWith("si_"), {
|
|
message: "subscriptionItemId must start with 'si_'",
|
|
})
|
|
.nullable(),
|
|
})
|
|
.partial()
|
|
.nullable();
|
|
|
|
export const bookingMetadataSchema = z
|
|
.object({
|
|
videoCallUrl: z.string().optional(),
|
|
})
|
|
.and(z.record(z.string()))
|
|
.nullable()
|
|
.describe("BookingMetadata");
|
|
|
|
export const customInputOptionSchema = z.array(
|
|
z.object({
|
|
label: z.string(),
|
|
type: z.string(),
|
|
})
|
|
);
|
|
|
|
export const customInputSchema = z.object({
|
|
id: z.number(),
|
|
eventTypeId: z.number(),
|
|
label: z.string(),
|
|
type: z.nativeEnum(EventTypeCustomInputType),
|
|
options: customInputOptionSchema.optional().nullable(),
|
|
required: z.boolean(),
|
|
placeholder: z.string(),
|
|
hasToBeCreated: z.boolean().optional(),
|
|
});
|
|
|
|
export type CustomInputSchema = z.infer<typeof customInputSchema>;
|
|
|
|
export const recordingItemSchema = z
|
|
.object({
|
|
id: z.string(),
|
|
room_name: z.string(),
|
|
start_ts: z.number(),
|
|
status: z.string(),
|
|
max_participants: z.number().optional(),
|
|
duration: z.number(),
|
|
share_token: z.string(),
|
|
})
|
|
.passthrough();
|
|
|
|
export const recordingItemsSchema = z.array(recordingItemSchema);
|
|
|
|
export type RecordingItemSchema = z.infer<typeof recordingItemSchema>;
|
|
|
|
export const getRecordingsResponseSchema = z.union([
|
|
z.object({
|
|
total_count: z.number(),
|
|
data: recordingItemsSchema,
|
|
}),
|
|
z.object({}),
|
|
]);
|
|
|
|
export type GetRecordingsResponseSchema = z.infer<typeof getRecordingsResponseSchema>;
|
|
|
|
/**
|
|
* Ensures that it is a valid HTTP URL
|
|
* It automatically avoids
|
|
* - XSS attempts through javascript:alert('hi')
|
|
* - mailto: links
|
|
*/
|
|
export const successRedirectUrl = z
|
|
.union([
|
|
z.literal(""),
|
|
z
|
|
.string()
|
|
.url()
|
|
.regex(/^http(s)?:\/\/.*/),
|
|
])
|
|
.optional();
|
|
|
|
export const RoutingFormSettings = z
|
|
.object({
|
|
// Applicable only for User Forms
|
|
emailOwnerOnSubmission: z.boolean(),
|
|
|
|
// Applicable only for Team Forms
|
|
sendUpdatesTo: z.array(z.number()).optional(),
|
|
sendToAll: z.boolean().optional(),
|
|
})
|
|
.nullable();
|
|
|
|
export const DeploymentTheme = z
|
|
.object({
|
|
brand: z.string().default("#292929"),
|
|
textBrand: z.string().default("#ffffff"),
|
|
darkBrand: z.string().default("#fafafa"),
|
|
textDarkBrand: z.string().default("#292929"),
|
|
bookingHighlight: z.string().default("#10B981"),
|
|
bookingLightest: z.string().default("#E1E1E1"),
|
|
bookingLighter: z.string().default("#ACACAC"),
|
|
bookingLight: z.string().default("#888888"),
|
|
bookingMedian: z.string().default("#494949"),
|
|
bookingDark: z.string().default("#313131"),
|
|
bookingDarker: z.string().default("#292929"),
|
|
fontName: z.string().default("Cal Sans"),
|
|
fontSrc: z.string().default("https://cal.com/cal.ttf"),
|
|
})
|
|
.optional();
|
|
|
|
export type ZodDenullish<T extends ZodTypeAny> = T extends ZodNullable<infer U> | ZodOptional<infer U>
|
|
? ZodDenullish<U>
|
|
: T;
|
|
|
|
export type ZodDenullishShape<T extends ZodRawShape> = {
|
|
[k in keyof T]: ZodDenullish<T[k]>;
|
|
};
|
|
|
|
export const denullish = <T extends ZodTypeAny>(schema: T): ZodDenullish<T> =>
|
|
(schema instanceof ZodNullable || schema instanceof ZodOptional
|
|
? denullish((schema._def as ZodNullableDef | ZodOptionalDef).innerType)
|
|
: schema) as ZodDenullish<T>;
|
|
|
|
type UnknownKeysParam = "passthrough" | "strict" | "strip";
|
|
|
|
/**
|
|
* @see https://github.com/3x071c/lsg-remix/blob/e2a9592ba3ec5103556f2cf307c32f08aeaee32d/app/lib/util/zod.ts
|
|
*/
|
|
export function denullishShape<
|
|
T extends ZodRawShape,
|
|
UnknownKeys extends UnknownKeysParam = "strip",
|
|
Catchall extends ZodTypeAny = ZodTypeAny,
|
|
Output = objectOutputType<T, Catchall>,
|
|
Input = objectInputType<T, Catchall>
|
|
>(
|
|
obj: ZodObject<T, UnknownKeys, Catchall, Output, Input>
|
|
): ZodObject<ZodDenullishShape<T>, UnknownKeys, Catchall> {
|
|
const a = entries(obj.shape).map(([field, schema]) => [field, denullish(schema)] as const) as {
|
|
[K in keyof T]: [K, ZodDenullish<T[K]>];
|
|
}[keyof T][];
|
|
return new ZodObject({
|
|
...obj._def,
|
|
shape: () => fromEntries(a) as unknown as ZodDenullishShape<T>, // TODO: Safely assert type
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Like Object.entries, but with actually useful typings
|
|
* @param obj The object to turn into a tuple array (`[key, value][]`)
|
|
* @returns The constructed tuple array from the given object
|
|
* @see https://github.com/3x071c/lsg-remix/blob/e2a9592ba3ec5103556f2cf307c32f08aeaee32d/app/lib/util/entries.ts
|
|
*/
|
|
export const entries = <O extends Record<string, unknown>>(
|
|
obj: O
|
|
): {
|
|
readonly [K in keyof O]: [K, O[K]];
|
|
}[keyof O][] => {
|
|
return Object.entries(obj) as {
|
|
[K in keyof O]: [K, O[K]];
|
|
}[keyof O][];
|
|
};
|
|
|
|
/**
|
|
* Returns a type with all readonly notations removed (traverses recursively on an object)
|
|
*/
|
|
type DeepWriteable<T> = T extends Readonly<{
|
|
-readonly [K in keyof T]: T[K];
|
|
}>
|
|
? {
|
|
-readonly [K in keyof T]: DeepWriteable<T[K]>;
|
|
}
|
|
: T; /* Make it work with readonly types (this is not strictly necessary) */
|
|
|
|
type FromEntries<T> = T extends [infer Keys, unknown][]
|
|
? { [K in Keys & PropertyKey]: Extract<T[number], [K, unknown]>[1] }
|
|
: never;
|
|
|
|
/**
|
|
* Like Object.fromEntries, but with actually useful typings
|
|
* @param arr The tuple array (`[key, value][]`) to turn into an object
|
|
* @returns Object constructed from the given entries
|
|
* @see https://github.com/3x071c/lsg-remix/blob/e2a9592ba3ec5103556f2cf307c32f08aeaee32d/app/lib/util/fromEntries.ts
|
|
*/
|
|
export const fromEntries = <
|
|
E extends [PropertyKey, unknown][] | ReadonlyArray<readonly [PropertyKey, unknown]>
|
|
>(
|
|
entries: E
|
|
): FromEntries<DeepWriteable<E>> => {
|
|
return Object.fromEntries(entries) as FromEntries<DeepWriteable<E>>;
|
|
};
|
|
|
|
export const getAccessLinkResponseSchema = z.object({
|
|
download_link: z.string().url(),
|
|
});
|
|
|
|
export type GetAccessLinkResponseSchema = z.infer<typeof getAccessLinkResponseSchema>;
|
|
|
|
/** Facilitates converting values from Select inputs to plain ones before submitting */
|
|
export const optionToValueSchema = <T extends z.ZodTypeAny>(valueSchema: T) =>
|
|
z
|
|
.object({
|
|
label: z.string(),
|
|
value: valueSchema,
|
|
})
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
.transform((foo) => (foo as any).value as z.infer<T>);
|
|
|
|
/**
|
|
* Allows parsing without losing original data inference.
|
|
* @url https://github.com/colinhacks/zod/discussions/1655#discussioncomment-4367368
|
|
*/
|
|
export const getParserWithGeneric =
|
|
<T extends AnyZodObject>(valueSchema: T) =>
|
|
<Data>(data: Data) => {
|
|
type Output = z.infer<T>;
|
|
type SimpleFormValues = string | number | null | undefined;
|
|
return valueSchema.parse(data) as {
|
|
// TODO: Invesitage why this broke on zod 3.22.2 upgrade
|
|
[key in keyof Data]: Data[key] extends SimpleFormValues ? Data[key] : Output[key];
|
|
};
|
|
};
|
|
export const sendDailyVideoRecordingEmailsSchema = z.object({
|
|
recordingId: z.string(),
|
|
bookingUID: z.string(),
|
|
});
|
|
|
|
export const downloadLinkSchema = z.object({
|
|
download_link: z.string(),
|
|
});
|
|
|
|
// All properties within event type that can and will be updated if needed
|
|
export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect, "id">]: true } = {
|
|
title: true,
|
|
description: true,
|
|
interfaceLanguage: true,
|
|
isInstantEvent: true,
|
|
instantMeetingParameters: true,
|
|
instantMeetingExpiryTimeOffsetInSeconds: true,
|
|
aiPhoneCallConfig: true,
|
|
currency: true,
|
|
periodDays: true,
|
|
position: true,
|
|
price: true,
|
|
slug: true,
|
|
length: true,
|
|
offsetStart: true,
|
|
locations: true,
|
|
hidden: true,
|
|
availability: true,
|
|
recurringEvent: true,
|
|
customInputs: true,
|
|
disableGuests: true,
|
|
disableCancelling: true,
|
|
disableRescheduling: true,
|
|
allowReschedulingCancelledBookings: true,
|
|
requiresConfirmation: true,
|
|
canSendCalVideoTranscriptionEmails: true,
|
|
requiresConfirmationForFreeEmail: true,
|
|
requiresConfirmationWillBlockSlot: true,
|
|
eventName: true,
|
|
metadata: true,
|
|
children: true,
|
|
hideCalendarNotes: true,
|
|
hideCalendarEventDetails: true,
|
|
minimumBookingNotice: true,
|
|
beforeEventBuffer: true,
|
|
afterEventBuffer: true,
|
|
successRedirectUrl: true,
|
|
seatsPerTimeSlot: true,
|
|
seatsShowAttendees: true,
|
|
seatsShowAvailabilityCount: true,
|
|
forwardParamsSuccessRedirect: true,
|
|
periodType: true,
|
|
hashedLink: true,
|
|
webhooks: true,
|
|
periodStartDate: true,
|
|
periodEndDate: true,
|
|
destinationCalendar: true,
|
|
periodCountCalendarDays: true,
|
|
bookingLimits: true,
|
|
onlyShowFirstAvailableSlot: true,
|
|
showOptimizedSlots: true,
|
|
slotInterval: true,
|
|
scheduleId: true,
|
|
workflows: true,
|
|
bookingFields: true,
|
|
durationLimits: true,
|
|
maxActiveBookingsPerBooker: true,
|
|
maxActiveBookingPerBookerOfferReschedule: true,
|
|
lockTimeZoneToggleOnBookingPage: true,
|
|
lockedTimeZone: true,
|
|
requiresBookerEmailVerification: true,
|
|
assignAllTeamMembers: true,
|
|
isRRWeightsEnabled: true,
|
|
eventTypeColor: true,
|
|
allowReschedulingPastBookings: true,
|
|
hideOrganizerEmail: true,
|
|
rescheduleWithSameRoundRobinHost: true,
|
|
maxLeadThreshold: true,
|
|
customReplyToEmail: true,
|
|
bookingRequiresAuthentication: true,
|
|
};
|
|
|
|
// All properties that are defined as unlocked based on all managed props
|
|
// Eventually this is going to be just a default and the user can change the config through the UI
|
|
export const unlockedManagedEventTypeProps = {
|
|
locations: allManagedEventTypeProps.locations,
|
|
scheduleId: allManagedEventTypeProps.scheduleId,
|
|
destinationCalendar: allManagedEventTypeProps.destinationCalendar,
|
|
};
|
|
|
|
export const emailSchema = emailRegexSchema;
|
|
|
|
// The PR at https://github.com/colinhacks/zod/pull/2157 addresses this issue and improves email validation
|
|
// I introduced this refinement(to be used with z.email()) as a short term solution until we upgrade to a zod
|
|
// version that will include updates in the above PR.
|
|
export const emailSchemaRefinement = (value: string) => {
|
|
return emailSchema.safeParse(value).success;
|
|
};
|
|
|
|
export const signupSchema = z.object({
|
|
// Username is marked optional here because it's requirement depends on if it's the Organization invite or a team invite which isn't easily done in zod
|
|
// It's better handled beyond zod in `validateAndGetCorrectedUsernameAndEmail`
|
|
username: z.string().optional(),
|
|
email: z.string().regex(emailRegex, { message: "Invalid email" }),
|
|
password: z.string().superRefine((data, ctx) => {
|
|
const isStrict = false;
|
|
const result = isPasswordValid(data, true, isStrict);
|
|
Object.keys(result).map((key: string) => {
|
|
if (!result[key as keyof typeof result]) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: [key],
|
|
message: key,
|
|
});
|
|
}
|
|
});
|
|
}),
|
|
language: z.string().optional(),
|
|
token: z.string().optional(),
|
|
});
|
|
|
|
export const ZVerifyCodeInputSchema = z.object({
|
|
email: emailSchema,
|
|
code: z.string(),
|
|
});
|
|
|
|
export type ZVerifyCodeInputSchema = z.infer<typeof ZVerifyCodeInputSchema>;
|
|
|
|
export const coerceToDate = z.coerce.date();
|
|
export const getStringAsNumberRequiredSchema = (t: TFunction) =>
|
|
z.string().min(1, t("error_required_field")).pipe(z.coerce.number());
|
|
|
|
export const bookingSeatDataSchema = z.object({
|
|
description: z.string().optional(),
|
|
responses: bookingResponses,
|
|
});
|
|
|
|
// Schema for decrypted service account key
|
|
export const serviceAccountKeySchema = z
|
|
.object({
|
|
private_key: z.string(),
|
|
client_email: z.string().optional(),
|
|
client_id: z.string(),
|
|
tenant_id: z.string().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
export type TServiceAccountKeySchema = z.infer<typeof serviceAccountKeySchema>;
|
|
|
|
export const rrSegmentQueryValueSchema = zodAttributesQueryValue.nullish();
|
|
|
|
// Routing Form Fields
|
|
export const fieldTypeEnum = z.enum([
|
|
"name",
|
|
"text",
|
|
"textarea",
|
|
"number",
|
|
"email",
|
|
"phone",
|
|
"address",
|
|
"multiemail",
|
|
"select",
|
|
"multiselect",
|
|
"checkbox",
|
|
"radio",
|
|
"radioInput",
|
|
"boolean",
|
|
"url",
|
|
]);
|
|
|
|
export type FieldType = z.infer<typeof fieldTypeEnum>;
|
|
|
|
export const excludeOrRequireEmailSchema = z.string().superRefine((val, ctx) => {
|
|
const allDomains = val.split(",").map((dom) => dom.trim());
|
|
|
|
const regex = /^(?:@?[a-z0-9-]+(?:\.[a-z]{2,})?)?(?:@[a-z0-9-]+\.[a-z]{2,})?$/i;
|
|
|
|
/*
|
|
Valid patterns - [ example, example.anything, anyone@example.anything ]
|
|
Invalid patterns - Patterns involving capital letter [ Example, Example.anything, Anyone@example.anything ]
|
|
*/
|
|
|
|
const isValid = !allDomains.some((domain) => !regex.test(domain));
|
|
|
|
if (!isValid) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Enter valid domain or email",
|
|
});
|
|
}
|
|
});
|
|
|
|
export const EditableSchema = z.enum([
|
|
"system", // Can't be deleted, can't be hidden, name can't be edited, can't be marked optional
|
|
"system-but-optional", // Can't be deleted. Name can't be edited. But can be hidden or be marked optional
|
|
"system-but-hidden", // Can't be deleted, name can't be edited, will be shown
|
|
"user", // Fully editable
|
|
"user-readonly", // All fields are readOnly.
|
|
]);
|
|
|
|
export const baseFieldSchema = z.object({
|
|
name: z.string().transform(getValidRhfFieldName),
|
|
type: fieldTypeEnum,
|
|
// TODO: We should make at least one of `defaultPlaceholder` and `placeholder` required. Do the same for label.
|
|
label: z.string().optional(),
|
|
labelAsSafeHtml: z.string().optional(),
|
|
|
|
/**
|
|
* It is the default label that will be used when a new field is created.
|
|
* Note: It belongs in FieldsTypeConfig, so that changing defaultLabel in code can work for existing fields as well(for fields that are using the default label).
|
|
* Supports translation
|
|
*/
|
|
defaultLabel: z.string().optional(),
|
|
|
|
placeholder: z.string().optional(),
|
|
/**
|
|
* It is the default placeholder that will be used when a new field is created.
|
|
* Note: Same as defaultLabel, it belongs in FieldsTypeConfig
|
|
* Supports translation
|
|
*/
|
|
defaultPlaceholder: z.string().optional(),
|
|
required: z.boolean().default(false).optional(),
|
|
/**
|
|
* It is the list of options that is valid for a certain type of fields.
|
|
*
|
|
*/
|
|
options: z
|
|
.array(
|
|
z.object({
|
|
label: z.string(),
|
|
value: z.string(),
|
|
price: z.coerce.number().min(0).optional(),
|
|
})
|
|
)
|
|
.optional(),
|
|
/**
|
|
* This is an alternate way to specify options when the options are stored elsewhere. Form Builder expects options to be present at `dataStore[getOptionsAt]`
|
|
* This allows keeping a single source of truth in DB.
|
|
*/
|
|
getOptionsAt: z.string().optional(),
|
|
|
|
/**
|
|
* For `radioInput` type of questions, it stores the input that is shown based on the user option selected.
|
|
* e.g. If user is given a list of locations and he selects "Phone", then he will be shown a phone input
|
|
*/
|
|
optionsInputs: z
|
|
.record(
|
|
z.object({
|
|
// Support all types as needed
|
|
// Must be a subset of `fieldTypeEnum`.TODO: Enforce it in TypeScript
|
|
type: z.enum(["address", "phone", "text"]),
|
|
required: z.boolean().optional(),
|
|
placeholder: z.string().optional(),
|
|
})
|
|
)
|
|
.optional(),
|
|
|
|
/**
|
|
* It is the minimum number of characters that can be entered in the field.
|
|
* It is used for types with `supportsLengthCheck= true`.
|
|
* @default 0
|
|
* @requires supportsLengthCheck = true
|
|
*/
|
|
minLength: z.number().optional(),
|
|
|
|
/**
|
|
* It is the maximum number of characters that can be entered in the field.
|
|
* It is used for types with `supportsLengthCheck= true`.
|
|
* @requires supportsLengthCheck = true
|
|
*/
|
|
maxLength: z.number().optional(),
|
|
|
|
// Emails that needs to be excluded
|
|
excludeEmails: excludeOrRequireEmailSchema.optional(),
|
|
// Emails that need to be required
|
|
requireEmails: excludeOrRequireEmailSchema.optional(),
|
|
// Price associated with the field which works like addons which users can add to the booking
|
|
price: z.coerce.number().min(0).optional(),
|
|
});
|
|
|
|
export const variantsConfigSchema = z.object({
|
|
variants: z.record(
|
|
z.object({
|
|
/**
|
|
* Variant Fields schema for a variant of the main field.
|
|
* It doesn't support non text fields as of now
|
|
**/
|
|
fields: baseFieldSchema
|
|
.omit({
|
|
defaultLabel: true,
|
|
defaultPlaceholder: true,
|
|
options: true,
|
|
getOptionsAt: true,
|
|
optionsInputs: true,
|
|
})
|
|
.array(),
|
|
})
|
|
),
|
|
});
|
|
|
|
export const fieldSchema = baseFieldSchema.merge(
|
|
z.object({
|
|
variant: z.string().optional(),
|
|
variantsConfig: variantsConfigSchema.optional(),
|
|
|
|
views: z
|
|
.object({
|
|
label: z.string(),
|
|
id: z.string(),
|
|
description: z.string().optional(),
|
|
})
|
|
.array()
|
|
.optional(),
|
|
|
|
/**
|
|
* It is used to hide fields such as location when there are less than two options
|
|
*/
|
|
hideWhenJustOneOption: z.boolean().default(false).optional(),
|
|
|
|
hidden: z.boolean().optional(),
|
|
editable: EditableSchema.default("user").optional(),
|
|
sources: z
|
|
.array(
|
|
z.object({
|
|
// Unique ID for the `type`. If type is workflow, it's the workflow ID
|
|
id: z.string(),
|
|
type: z.union([z.literal("user"), z.literal("system"), z.string()]),
|
|
label: z.string(),
|
|
editUrl: z.string().optional(),
|
|
// Mark if a field is required by this source or not. This allows us to set `field.required` based on all the sources' fieldRequired value
|
|
fieldRequired: z.boolean().optional(),
|
|
})
|
|
)
|
|
.optional(),
|
|
disableOnPrefill: z.boolean().default(false).optional(),
|
|
})
|
|
);
|
|
|
|
export const eventTypeBookingFields = z.array(fieldSchema);
|
|
export const BookingFieldTypeEnum = eventTypeBookingFields.element.shape.type.Enum;
|