diff --git a/.env.example b/.env.example index b04ed91301dd36..65c95204332a8a 100644 --- a/.env.example +++ b/.env.example @@ -261,6 +261,9 @@ EMAIL_SERVER_PORT=1025 ## @see https://support.google.com/accounts/answer/185833 # EMAIL_SERVER_PASSWORD='' +# queue or cancel payment reminder email/flow +AWAITING_PAYMENT_EMAIL_DELAY_MINUTES= + # Used for E2E for email testing # Set it to "1" if you need to email checks in E2E tests locally # Make sure to run mailhog container manually or with `yarn dx` diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/booking/[uid]/logs/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/booking/[uid]/logs/page.tsx index 5d5a072db4d17c..ebe3c5c11b6165 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/booking/[uid]/logs/page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/booking/[uid]/logs/page.tsx @@ -11,7 +11,7 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; -import BookingLogsView from "~/booking/logs/views/booking-logs-view"; +import { BookingHistoryPage } from "@calcom/features/booking-audit/client/components/BookingHistoryPage"; export const generateMetadata = async ({ params }: { params: Promise<{ uid: string }> }) => await _generateMetadata( @@ -39,7 +39,7 @@ const Page = async ({ params }: PageProps) => { return ( - + ); }; diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx index 2176941549f13b..aad91983df03a1 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx @@ -55,9 +55,12 @@ const Page = async ({ params }: PageProps) => { } const featuresRepository = new FeaturesRepository(prisma); - const bookingsV3Enabled = session?.user?.id - ? await featuresRepository.checkIfUserHasFeature(session.user.id, "bookings-v3") - : false; + const featureFlags = session?.user?.id + ? await featuresRepository.getUserFeaturesStatus(session.user.id, ["bookings-v3", "booking-audit"]) + : { "bookings-v3": false, "booking-audit": false }; + + const bookingsV3Enabled = featureFlags["bookings-v3"] ?? false; + const bookingAuditEnabled = featureFlags["booking-audit"] ?? false; return ( { userId={session?.user?.id} permissions={{ canReadOthersBookings }} bookingsV3Enabled={bookingsV3Enabled} + bookingAuditEnabled={bookingAuditEnabled} /> ); diff --git a/apps/web/modules/bookings/components/BookingDetailsSheet.tsx b/apps/web/modules/bookings/components/BookingDetailsSheet.tsx index 8dd1a554f61bde..b6bef505af30c0 100644 --- a/apps/web/modules/bookings/components/BookingDetailsSheet.tsx +++ b/apps/web/modules/bookings/components/BookingDetailsSheet.tsx @@ -45,7 +45,8 @@ import { usePaymentStatus } from "../hooks/usePaymentStatus"; import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore"; import type { BookingOutput } from "../types"; import { JoinMeetingButton } from "./JoinMeetingButton"; - +import { BookingHistory } from "@calcom/features/booking-audit/client/components/BookingHistory"; +import { SegmentedControl } from "@calcom/ui/components/segmented-control"; type BookingMetaData = z.infer; interface BookingDetailsSheetProps { @@ -53,6 +54,7 @@ interface BookingDetailsSheetProps { userTimeFormat?: number; userId?: number; userEmail?: string; + bookingAuditEnabled?: boolean; } export function BookingDetailsSheet({ @@ -60,6 +62,7 @@ export function BookingDetailsSheet({ userTimeFormat, userId, userEmail, + bookingAuditEnabled = false, }: BookingDetailsSheetProps) { const booking = useBookingDetailsSheetStore((state) => state.getSelectedBooking()); @@ -74,6 +77,7 @@ export function BookingDetailsSheet({ userTimeFormat={userTimeFormat} userId={userId} userEmail={userEmail} + bookingAuditEnabled={bookingAuditEnabled} /> ); @@ -85,6 +89,26 @@ interface BookingDetailsSheetInnerProps { userTimeFormat?: number; userId?: number; userEmail?: string; + bookingAuditEnabled?: boolean; +} + +function useActiveSegment(bookingAuditEnabled: boolean) { + const [activeSegment, setActiveSegmentInStore] = useBookingDetailsSheetStore((state) => [state.activeSegment, state.setActiveSegment]); + + const getDerivedActiveSegment = ({ activeSegment, bookingAuditEnabled }: { activeSegment: "info" | "history" | null, bookingAuditEnabled: boolean }) => { + if (!bookingAuditEnabled && activeSegment === "history") { + return "info"; + } + return activeSegment ?? "info"; + } + + const derivedActiveSegment = getDerivedActiveSegment({ activeSegment, bookingAuditEnabled }); + + const setDerivedActiveSegment = (segment: "info" | "history") => { + setActiveSegmentInStore(getDerivedActiveSegment({ activeSegment: segment, bookingAuditEnabled })); + }; + + return [derivedActiveSegment, setDerivedActiveSegment] as const; } function BookingDetailsSheetInner({ @@ -93,8 +117,10 @@ function BookingDetailsSheetInner({ userTimeFormat, userId, userEmail, + bookingAuditEnabled = false, }: BookingDetailsSheetInnerProps) { const { t } = useLocale(); + const [activeSegment, setActiveSegment] = useActiveSegment(bookingAuditEnabled); // Fetch additional booking details for reschedule information const { data: bookingDetails } = trpc.viewer.bookings.getBookingDetails.useQuery( @@ -117,6 +143,7 @@ function BookingDetailsSheetInner({ navigatePrevious: state.navigatePrevious, isTransitioning: state.isTransitioning, setSelectedBookingUid: state.setSelectedBookingUid, + setActiveSegment: state.setActiveSegment, canGoNext: hasNextInArray || (isLastInArray && state.capabilities?.canNavigateToNextPeriod()), canGoPrev: hasPreviousInArray || (isFirstInArray && state.capabilities?.canNavigateToPreviousPeriod()), }; @@ -124,6 +151,7 @@ function BookingDetailsSheetInner({ const handleClose = () => { navigation.setSelectedBookingUid(null); + navigation.setActiveSegment(null); }; const handleNext = () => { @@ -167,15 +195,15 @@ function BookingDetailsSheetInner({ const recurringInfo = booking.recurringEventId && booking.eventType?.recurringEvent ? { - count: booking.eventType.recurringEvent.count, - recurringEvent: booking.eventType.recurringEvent, - } + count: booking.eventType.recurringEvent.count, + recurringEvent: booking.eventType.recurringEvent, + } : null; const customResponses = booking.responses ? Object.entries(booking.responses as Record) - .filter(([fieldName]) => shouldShowFieldInCustomResponses(fieldName)) - .map(([question, answer]) => [question, answer] as [string, unknown]) + .filter(([fieldName]) => shouldShowFieldInCustomResponses(fieldName)) + .map(([question, answer]) => [question, answer] as [string, unknown]) : []; const reason = booking.assignmentReason?.[0]; @@ -254,43 +282,59 @@ function BookingDetailsSheetInner({ - + {bookingAuditEnabled && ( + setActiveSegment(value)} + /> + )} - + {activeSegment === "info" && ( + <> + - + - + - + - + - + - + - {booking.payment?.[0] && } + - + {booking.payment?.[0] && } - + - + - + + + + + )} + + {bookingAuditEnabled && activeSegment === "history" && ( + + )} diff --git a/apps/web/modules/bookings/components/BookingListContainer.tsx b/apps/web/modules/bookings/components/BookingListContainer.tsx index 6a01a5878cebfe..470272f81fbbe5 100644 --- a/apps/web/modules/bookings/components/BookingListContainer.tsx +++ b/apps/web/modules/bookings/components/BookingListContainer.tsx @@ -71,6 +71,7 @@ interface BookingListContainerProps { canReadOthersBookings: boolean; }; bookingsV3Enabled: boolean; + bookingAuditEnabled: boolean; } interface BookingListInnerProps extends BookingListContainerProps { @@ -87,6 +88,7 @@ function BookingListInner({ permissions, bookings, bookingsV3Enabled, + bookingAuditEnabled, data, isPending, hasError, @@ -221,6 +223,7 @@ function BookingListInner({ userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat} userId={user?.id} userEmail={user?.email} + bookingAuditEnabled={bookingAuditEnabled} /> )} diff --git a/apps/web/modules/bookings/hooks/useActiveSegmentFromUrl.ts b/apps/web/modules/bookings/hooks/useActiveSegmentFromUrl.ts new file mode 100644 index 00000000000000..12dd1c2d715da1 --- /dev/null +++ b/apps/web/modules/bookings/hooks/useActiveSegmentFromUrl.ts @@ -0,0 +1,13 @@ +import { useQueryState } from "nuqs"; + +export function useActiveSegmentFromUrl() { + return useQueryState<"info" | "history">("activeSegment", { + defaultValue: "info", + parse: (value) => { + if (!value) return "info"; + if (value === "history") return "history"; + return "info"; + }, + }); +} + diff --git a/apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx b/apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx index 21e5bed8fc96d5..7f4e45f6e82afe 100644 --- a/apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx +++ b/apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from "react"; import { createStore, useStore } from "zustand"; +import { useActiveSegmentFromUrl } from "../hooks/useActiveSegmentFromUrl"; import { useSelectedBookingUid } from "../hooks/useSelectedBookingUid"; import type { BookingOutput } from "../types"; @@ -44,6 +45,7 @@ export interface NavigationCapabilities { interface BookingDetailsSheetStore { // Core state (view-agnostic) selectedBookingUid: string | null; + activeSegment: "info" | "history" | null; bookings: BookingOutput[]; isTransitioning: boolean; pendingSelection: PendingSelectionType; @@ -53,6 +55,7 @@ interface BookingDetailsSheetStore { // Core actions setSelectedBookingUid: (uid: string | null) => void; + setActiveSegment: (segment: "info" | "history" | null) => void; setBookings: (bookings: BookingOutput[]) => void; setCapabilities: (capabilities: NavigationCapabilities | null) => void; clearSelection: () => void; @@ -78,6 +81,7 @@ const createBookingDetailsSheetStore = (initialBookings: BookingOutput[] = []) = return createStore((set, get) => ({ // Initial state selectedBookingUid: null, + activeSegment: null, bookings: initialBookings, isTransitioning: false, pendingSelection: null, @@ -87,6 +91,9 @@ const createBookingDetailsSheetStore = (initialBookings: BookingOutput[] = []) = setSelectedBookingUid: (uid) => { set({ selectedBookingUid: uid }); }, + setActiveSegment: (segment) => { + set({ activeSegment: segment }); + }, setBookings: (bookings) => set({ bookings }), setCapabilities: (capabilities) => set({ capabilities }), clearSelection: () => set({ selectedBookingUid: null }), @@ -169,6 +176,45 @@ const createBookingDetailsSheetStore = (initialBookings: BookingOutput[] = []) = const BookingDetailsSheetStoreContext = React.createContext(null); +// TODO: To Avoid this useEffect based double sync, we should return a wrapper store over Zustand and Nuqs. +// Certain states that are stored in query params would be fully powered by Nuqs and other states would be fully powered by Zustand and the wrapper store provides a generic interface to work with both +function useBiDirectionalSyncBetweenStoreAndUrl({ store }: { store: BookingDetailsSheetStoreType }) { + const [selectedBookingUidFromUrl, setSelectedBookingUidToUrl] = useSelectedBookingUid(); + const [activeSegmentFromUrl, setActiveSegmentToUrl] = useActiveSegmentFromUrl(); + const isSyncedFromUrlToStoreRef = useRef(false); + + // Sync Store → URL + useEffect(() => { + // We can't sync from Store to URL if URL hasn't first been synced to Store + // This is to prevent override of any user configuration provided by URL query params. Think about page being refreshed + if (!isSyncedFromUrlToStoreRef.current) return; + + const unsubscribe = store.subscribe((state) => { + if (state.selectedBookingUid !== selectedBookingUidFromUrl) { + setSelectedBookingUidToUrl(state.selectedBookingUid); + } + if (state.activeSegment !== activeSegmentFromUrl) { + setActiveSegmentToUrl(state.activeSegment); + } + }); + + return unsubscribe; + }, [selectedBookingUidFromUrl, activeSegmentFromUrl, store]); + + // Sync URL → Store + useEffect(() => { + const state = store.getState(); + if (selectedBookingUidFromUrl !== state.selectedBookingUid) { + state.setSelectedBookingUid(selectedBookingUidFromUrl); + } + + if (activeSegmentFromUrl !== state.activeSegment) { + state.setActiveSegment(activeSegmentFromUrl); + } + isSyncedFromUrlToStoreRef.current = true; + }, [selectedBookingUidFromUrl, activeSegmentFromUrl, store]); +} + export function BookingDetailsSheetStoreProvider({ children, bookings, @@ -179,7 +225,6 @@ export function BookingDetailsSheetStoreProvider({ capabilities?: NavigationCapabilities | null; }) { const [store] = useState(() => createBookingDetailsSheetStore(bookings)); - const [selectedBookingUidFromUrl, setSelectedBookingUidToUrl] = useSelectedBookingUid(); const previousBookingsRef = useRef(bookings); // Update bookings in store @@ -198,25 +243,7 @@ export function BookingDetailsSheetStoreProvider({ store.getState().setCapabilities(capabilities ?? null); }, [capabilities, store]); - // Sync Store → URL - useEffect(() => { - const unsubscribe = store.subscribe((state) => { - const storeUid = state.selectedBookingUid; - if (storeUid !== selectedBookingUidFromUrl) { - setSelectedBookingUidToUrl(storeUid); - } - }); - - return unsubscribe; - }, [selectedBookingUidFromUrl, setSelectedBookingUidToUrl, store]); - - // Sync URL → Store - useEffect(() => { - const currentStoreUid = store.getState().selectedBookingUid; - if (currentStoreUid !== selectedBookingUidFromUrl) { - store.getState().setSelectedBookingUid(selectedBookingUidFromUrl); - } - }, [selectedBookingUidFromUrl, store]); + useBiDirectionalSyncBetweenStoreAndUrl({ store }); return ( diff --git a/apps/web/modules/bookings/views/bookings-single-view.tsx b/apps/web/modules/bookings/views/bookings-single-view.tsx index e7acd2d831970b..19ea066159810d 100644 --- a/apps/web/modules/bookings/views/bookings-single-view.tsx +++ b/apps/web/modules/bookings/views/bookings-single-view.tsx @@ -79,6 +79,7 @@ const querySchema = z.object({ seatReferenceUid: z.string().optional(), rating: z.string().optional(), noShow: stringToBoolean, + redirect_status: z.string().optional(), }); const useBrandColors = ({ @@ -120,6 +121,7 @@ export default function Success(props: PageProps) { seatReferenceUid, noShow, rating, + redirect_status, } = querySchema.parse(routerQuery); const attendeeTimeZone = bookingInfo?.attendees.find((attendee) => attendee.email === email)?.timeZone; @@ -148,7 +150,10 @@ export default function Success(props: PageProps) { const status = bookingInfo?.status; const reschedule = bookingInfo.status === BookingStatus.ACCEPTED; const cancellationReason = bookingInfo.cancellationReason || bookingInfo.rejectionReason; - const isAwaitingPayment = props.paymentStatus && !props.paymentStatus.success; + + const isPaymentSucceededFromRedirect = redirect_status === "succeeded"; + const isAwaitingPayment = + props.paymentStatus && !props.paymentStatus.success && !isPaymentSucceededFromRedirect; const attendees = bookingInfo?.attendees; @@ -499,7 +504,7 @@ export default function Success(props: PageProps) { {!isFeedbackMode && ( <>
+ className={classNames(isRoundRobin && "min-h-24 min-w-32 relative mx-auto h-24 w-32")}> {isRoundRobin && bookingInfo.user && ( {paymentStatusMessage}} -
+
{(isCancelled || reschedule) && cancellationReason && ( <>
@@ -1084,7 +1089,7 @@ export default function Success(props: PageProps) {
{isGmail && !isFeedbackMode && ( diff --git a/apps/web/modules/bookings/views/bookings-view.tsx b/apps/web/modules/bookings/views/bookings-view.tsx index 0eafa81e7c9cc6..f1404099977e47 100644 --- a/apps/web/modules/bookings/views/bookings-view.tsx +++ b/apps/web/modules/bookings/views/bookings-view.tsx @@ -27,6 +27,7 @@ type BookingsProps = { canReadOthersBookings: boolean; }; bookingsV3Enabled: boolean; + bookingAuditEnabled: boolean; }; function useSystemSegments(userId?: number) { @@ -68,7 +69,7 @@ export default function Bookings(props: BookingsProps) { ); } -function BookingsContent({ status, permissions, bookingsV3Enabled }: BookingsProps) { +function BookingsContent({ status, permissions, bookingsV3Enabled, bookingAuditEnabled }: BookingsProps) { const [view] = useBookingsView({ bookingsV3Enabled }); useBookingsShellHeadingVisibility({ visible: view === "list" }); @@ -80,6 +81,7 @@ function BookingsContent({ status, permissions, bookingsV3Enabled }: BookingsPro status={status} permissions={permissions} bookingsV3Enabled={bookingsV3Enabled} + bookingAuditEnabled={bookingAuditEnabled} /> )} {bookingsV3Enabled && view === "calendar" && ( diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 53d118a1a8ab41..6c3adf50dbfe07 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -403,6 +403,7 @@ "set_up_later": "Set up later", "current_time": "Current time", "details": "Details", + "info": "Info", "welcome": "Welcome", "welcome_back": "Welcome back", "welcome_to_calcom": "Welcome to {{appName}}", diff --git a/packages/app-store/_utils/payments/handlePaymentSuccess.ts b/packages/app-store/_utils/payments/handlePaymentSuccess.ts index 84d7848db3cc83..024056bb541891 100644 --- a/packages/app-store/_utils/payments/handlePaymentSuccess.ts +++ b/packages/app-store/_utils/payments/handlePaymentSuccess.ts @@ -8,6 +8,7 @@ import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirma import { getBooking } from "@calcom/features/bookings/lib/payment/getBooking"; import { getPlatformParams } from "@calcom/features/platform-oauth-client/get-platform-params"; import { PlatformOAuthClientRepository } from "@calcom/features/platform-oauth-client/platform-oauth-client.repository"; +import tasker from "@calcom/features/tasker"; import { HttpError as HttpCode } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import type { TraceContext } from "@calcom/lib/tracing"; @@ -26,6 +27,16 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number, log.debug(`handling payment success for bookingId ${bookingId}`); const { booking, user: userWithCredentials, evt, eventType } = await getBooking(bookingId); + try { + await tasker.cancelWithReference(booking.uid, "sendAwaitingPaymentEmail"); + log.debug(`Cancelled scheduled awaiting payment email for booking ${bookingId}`); + } catch (error) { + log.warn( + { bookingId, error }, + `Failed to cancel awaiting payment task - email may still be sent but will be suppressed by task handler` + ); + } + if (booking.location) evt.location = booking.location; const bookingData: Prisma.BookingUpdateInput = { diff --git a/packages/app-store/routing-forms/lib/findFieldValueByIdentifier.ts b/packages/app-store/routing-forms/lib/findFieldValueByIdentifier.ts index dc57a341bfa1c5..3582a8586aadb9 100644 --- a/packages/app-store/routing-forms/lib/findFieldValueByIdentifier.ts +++ b/packages/app-store/routing-forms/lib/findFieldValueByIdentifier.ts @@ -1,20 +1 @@ -import getFieldIdentifier from "./getFieldIdentifier"; -import type { RoutingFormResponseData } from "./responseData/types"; - -type FindFieldValueByIdentifierResult = - | { success: true; data: string | string[] | number | null } - | { success: false; error: string }; - -export function findFieldValueByIdentifier( - data: RoutingFormResponseData, - identifier: string -): FindFieldValueByIdentifierResult { - const field = data.fields.find((field) => getFieldIdentifier(field) === identifier); - if (!field) { - return { success: false, error: `Field with identifier ${identifier} not found` }; - } - - const fieldValue = data.response[field.id]?.value; - - return { success: true, data: fieldValue ?? null }; -} +export { findFieldValueByIdentifier } from "@calcom/features/routing-forms/lib/findFieldValueByIdentifier"; diff --git a/packages/app-store/routing-forms/lib/getFieldIdentifier.ts b/packages/app-store/routing-forms/lib/getFieldIdentifier.ts index 9d0eebdeaf58b0..87e22a49bb87bc 100644 --- a/packages/app-store/routing-forms/lib/getFieldIdentifier.ts +++ b/packages/app-store/routing-forms/lib/getFieldIdentifier.ts @@ -1,5 +1 @@ -import type { Field } from "../types/types"; - -const getFieldIdentifier = (field: Field) => field.identifier || field.label; - -export default getFieldIdentifier; +export { default } from "@calcom/features/routing-forms/lib/getFieldIdentifier"; diff --git a/packages/app-store/routing-forms/lib/responseData/parseRoutingFormResponse.ts b/packages/app-store/routing-forms/lib/responseData/parseRoutingFormResponse.ts index 438ffdb9d4abb7..96f04ab9706ebf 100644 --- a/packages/app-store/routing-forms/lib/responseData/parseRoutingFormResponse.ts +++ b/packages/app-store/routing-forms/lib/responseData/parseRoutingFormResponse.ts @@ -1,10 +1 @@ -import { zodNonRouterField } from "@calcom/app-store/routing-forms/zod"; -import { routingFormResponseInDbSchema } from "@calcom/app-store/routing-forms/zod"; - -import type { RoutingFormResponseData } from "./types"; - -export function parseRoutingFormResponse(rawResponse: unknown, formFields: unknown): RoutingFormResponseData { - const response = routingFormResponseInDbSchema.parse(rawResponse); - const fields = zodNonRouterField.array().parse(formFields); - return { response, fields }; -} +export { parseRoutingFormResponse } from "@calcom/features/routing-forms/lib/parseRoutingFormResponse"; diff --git a/packages/app-store/routing-forms/zod.ts b/packages/app-store/routing-forms/zod.ts index 8054b8fd9527e0..2045854e784b33 100644 --- a/packages/app-store/routing-forms/zod.ts +++ b/packages/app-store/routing-forms/zod.ts @@ -4,50 +4,15 @@ import { raqbQueryValueSchema } from "@calcom/lib/raqb/zod"; import { routingFormAppDataSchemas } from "./appDataSchemas"; -export type FieldOption = { - label: string; - id: string | null; -}; +export { + zodNonRouterField, + routingFormResponseInDbSchema, + type FieldOption, + type TNonRouterField, +} from "@calcom/features/routing-forms/lib/zod"; -export type TNonRouterField = { - id: string; - label: string; - identifier?: string; - placeholder?: string; - type: string; - /** @deprecated in favour of `options` */ - selectText?: string; - required?: boolean; - deleted?: boolean; - options?: FieldOption[]; -}; - -// Note: zodNonRouterField is NOT annotated with z.ZodType because it uses .extend() below -// which requires the full ZodObject type to be preserved -export const zodNonRouterField = z.object({ - id: z.string(), - label: z.string(), - identifier: z.string().optional(), - placeholder: z.string().optional(), - type: z.string(), - /** - * @deprecated in favour of `options` - */ - selectText: z.string().optional(), - required: z.boolean().optional(), - deleted: z.boolean().optional(), - options: z - .array( - z.object({ - label: z.string(), - // To keep backwards compatibility with the options generated from legacy selectText, we allow saving null as id - // It helps in differentiating whether the routing logic should consider the option.label as value or option.id as value. - // This is important for legacy routes which has option.label saved in conditions and it must keep matching with the value of the option - id: z.string().or(z.null()), - }) - ) - .optional(), -}); +import type { TNonRouterField } from "@calcom/features/routing-forms/lib/zod"; +import { zodNonRouterField } from "@calcom/features/routing-forms/lib/zod"; export type TRouterField = TNonRouterField & { routerId: string; @@ -158,12 +123,3 @@ export const zodRoutesView = z.union([z.array(zodRouteView), z.null()]).optional export const appDataSchema = z.any(); export const appKeysSchema = z.object({}); - -// This is different from FormResponse in types.d.ts in that it has label optional. We don't seem to be using label at this point, so we might want to use this only while saving the response when Routing Form is submitted -// Record key is formFieldId -export const routingFormResponseInDbSchema = z.record( - z.object({ - label: z.string().optional(), - value: z.union([z.string(), z.number(), z.array(z.string())]), - }) -); diff --git a/packages/app-store/stripepayment/lib/PaymentService.ts b/packages/app-store/stripepayment/lib/PaymentService.ts index cb1d1bc42ba6d7..7653e30a6a2424 100644 --- a/packages/app-store/stripepayment/lib/PaymentService.ts +++ b/packages/app-store/stripepayment/lib/PaymentService.ts @@ -2,13 +2,14 @@ import Stripe from "stripe"; import { v4 as uuidv4 } from "uuid"; import z from "zod"; -import { sendAwaitingPaymentEmailAndSMS } from "@calcom/emails/email-manager"; +import dayjs from "@calcom/dayjs"; import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; +import tasker from "@calcom/features/tasker"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { ErrorWithCode } from "@calcom/lib/errors"; import logger from "@calcom/lib/logger"; -import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown"; import { safeStringify } from "@calcom/lib/safeStringify"; +import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown"; import prisma from "@calcom/prisma"; import type { Booking, Payment, PaymentOption, Prisma } from "@calcom/prisma/client"; import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; @@ -16,7 +17,6 @@ import type { CalendarEvent } from "@calcom/types/Calendar"; import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; import { paymentOptionEnum } from "../zod"; -import { createPaymentLink } from "./client"; import { retrieveOrCreateStripeCustomerByEmail } from "./customer"; import type { StripePaymentData, StripeSetupIntentData } from "./server"; @@ -382,29 +382,24 @@ export class PaymentService implements IAbstractPaymentService { uid: string; }, paymentData: Payment, - eventTypeMetadata?: EventTypeMetadata + _eventTypeMetadata?: EventTypeMetadata ): Promise { - const attendeesToEmail = event.attendeeSeatId - ? event.attendees.filter((attendee) => attendee.bookingSeat?.referenceUid === event.attendeeSeatId) - : event.attendees; + const delayMinutes = Number(process.env.AWAITING_PAYMENT_EMAIL_DELAY_MINUTES) || 15; + const scheduledEmailAt = dayjs().add(delayMinutes, "minutes").toDate(); - await sendAwaitingPaymentEmailAndSMS( + // we give the user 15 minutes to complete the payment + // if the payment is still not processed after 15 minutes, we send an awaiting payment email + await tasker.create( + "sendAwaitingPaymentEmail", { - ...event, - attendees: attendeesToEmail, - paymentInfo: { - link: createPaymentLink({ - paymentUid: paymentData.uid, - name: booking.user?.name, - email: booking.user?.email, - date: booking.startTime.toISOString(), - }), - paymentOption: paymentData.paymentOption || "ON_BOOKING", - amount: paymentData.amount, - currency: paymentData.currency, - }, + bookingId: booking.id, + paymentId: paymentData.id, + attendeeSeatId: event.attendeeSeatId || null, }, - eventTypeMetadata + { + scheduledAt: scheduledEmailAt, + referenceUid: booking.uid, + } ); } diff --git a/apps/web/modules/booking/logs/views/booking-logs-view.tsx b/packages/features/booking-audit/client/components/BookingHistory.tsx similarity index 97% rename from apps/web/modules/booking/logs/views/booking-logs-view.tsx rename to packages/features/booking-audit/client/components/BookingHistory.tsx index d47fb528b1ed47..9c487a2c59e4bd 100644 --- a/apps/web/modules/booking/logs/views/booking-logs-view.tsx +++ b/packages/features/booking-audit/client/components/BookingHistory.tsx @@ -1,10 +1,6 @@ -/** - * TODO: Move it to features/booking-audit - */ "use client"; import Link from "next/link"; -import { useRouter } from "next/navigation"; import { useState } from "react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import dayjs from "@calcom/dayjs"; @@ -17,7 +13,7 @@ import { Avatar } from "@calcom/ui/components/avatar"; import ServerTrans from "@calcom/lib/components/ServerTrans"; import type { AuditActorType } from "@calcom/features/booking-audit/lib/repository/IAuditActorRepository"; -interface BookingLogsViewProps { +interface BookingHistoryProps { bookingUid: string; } @@ -76,7 +72,6 @@ const ACTION_ICON_MAP: Record = { ATTENDEE_NO_SHOW_UPDATED: "ban", } as const; - const ACTOR_ROLE_LABEL_MAP: Record = { GUEST: "guest", ATTENDEE: "attendee", @@ -361,12 +356,11 @@ function useBookingLogsFilters( return { filteredLogs, actorOptions }; } -export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) { - const router = useRouter(); +export function BookingHistory({ bookingUid }: BookingHistoryProps) { const [searchTerm, setSearchTerm] = useState(""); const [actorFilter, setActorFilter] = useState(null); const { t } = useLocale(); - const { data, isLoading, error } = trpc.viewer.bookings.getAuditLogs.useQuery({ + const { data, isLoading, error } = trpc.viewer.bookings.getBookingHistory.useQuery({ bookingUid, }); @@ -384,9 +378,6 @@ export default function BookingLogsView({ bookingUid }: BookingLogsViewProps) {

{t("error_loading_booking_logs")}

{error.message}

-
); diff --git a/packages/features/booking-audit/client/components/BookingHistoryPage.tsx b/packages/features/booking-audit/client/components/BookingHistoryPage.tsx new file mode 100644 index 00000000000000..b983a95f14462b --- /dev/null +++ b/packages/features/booking-audit/client/components/BookingHistoryPage.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { BookingHistory } from "./BookingHistory"; + +interface BookingHistoryPageProps { + bookingUid: string; +} + +/** + * BookingHistoryPage - Wrapper component for standalone page context + * Adds page-specific styling and layout for the booking history view + */ +export function BookingHistoryPage({ bookingUid }: BookingHistoryPageProps) { + return ; +} diff --git a/packages/features/booking-audit/di/BookingHistoryViewerService.container.ts b/packages/features/booking-audit/di/BookingHistoryViewerService.container.ts new file mode 100644 index 00000000000000..c20cd3e1b6b08a --- /dev/null +++ b/packages/features/booking-audit/di/BookingHistoryViewerService.container.ts @@ -0,0 +1,14 @@ +import { createContainer } from "@calcom/features/di/di"; + +import { + type BookingHistoryViewerService, + moduleLoader as bookingHistoryViewerServiceModule, +} from "./BookingHistoryViewerService.module"; + +const container = createContainer(); + +export function getBookingHistoryViewerService() { + bookingHistoryViewerServiceModule.loadModule(container); + + return container.get(bookingHistoryViewerServiceModule.token); +} diff --git a/packages/features/booking-audit/di/BookingHistoryViewerService.module.ts b/packages/features/booking-audit/di/BookingHistoryViewerService.module.ts new file mode 100644 index 00000000000000..e728da1c956e4b --- /dev/null +++ b/packages/features/booking-audit/di/BookingHistoryViewerService.module.ts @@ -0,0 +1,28 @@ +import { BookingHistoryViewerService } from "@calcom/features/booking-audit/lib/service/BookingHistoryViewerService"; +import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens"; +import { moduleLoader as bookingAuditViewerServiceModuleLoader } from "@calcom/features/booking-audit/di/BookingAuditViewerService.module"; +import { moduleLoader as routingFormResponseRepositoryModuleLoader } from "@calcom/features/routing-forms/di/RoutingFormResponseRepository.module"; + +import { createModule, bindModuleToClassOnToken } from "../../di/di"; + +export const bookingHistoryViewerServiceModule = createModule(); +const token = BOOKING_AUDIT_DI_TOKENS.BOOKING_HISTORY_VIEWER_SERVICE; +const moduleToken = BOOKING_AUDIT_DI_TOKENS.BOOKING_HISTORY_VIEWER_SERVICE_MODULE; + +export { BookingHistoryViewerService }; + +const loadModule = bindModuleToClassOnToken({ + module: bookingHistoryViewerServiceModule, + moduleToken, + token, + classs: BookingHistoryViewerService, + depsMap: { + bookingAuditViewerService: bookingAuditViewerServiceModuleLoader, + routingFormResponseRepository: routingFormResponseRepositoryModuleLoader, + }, +}); + +export const moduleLoader = { + token, + loadModule, +}; diff --git a/packages/features/booking-audit/di/tokens.ts b/packages/features/booking-audit/di/tokens.ts index 78472ceafb4dde..50b03f750a5d96 100644 --- a/packages/features/booking-audit/di/tokens.ts +++ b/packages/features/booking-audit/di/tokens.ts @@ -9,4 +9,6 @@ export const BOOKING_AUDIT_DI_TOKENS = { BOOKING_AUDIT_REPOSITORY_MODULE: Symbol("BookingAuditRepositoryModule"), AUDIT_ACTOR_REPOSITORY: Symbol("AuditActorRepository"), AUDIT_ACTOR_REPOSITORY_MODULE: Symbol("AuditActorRepositoryModule"), + BOOKING_HISTORY_VIEWER_SERVICE: Symbol("BookingHistoryViewerService"), + BOOKING_HISTORY_VIEWER_SERVICE_MODULE: Symbol("BookingHistoryViewerServiceModule"), }; diff --git a/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts index 165afacb21f5bf..e480b2345ec6ec 100644 --- a/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts +++ b/packages/features/booking-audit/lib/service/BookingAuditViewerService.ts @@ -48,6 +48,8 @@ type EnrichedAuditLog = { }; }; +export type DisplayBookingAuditLog = EnrichedAuditLog; + /** * BookingAuditViewerService - Service for viewing and formatting booking audit logs */ @@ -93,7 +95,7 @@ export class BookingAuditViewerService { userEmail: string; userTimeZone: string; organizationId: number | null; - }): Promise<{ bookingUid: string; auditLogs: EnrichedAuditLog[] }> { + }): Promise<{ bookingUid: string; auditLogs: DisplayBookingAuditLog[] }> { const { bookingUid, userId, userTimeZone, organizationId } = params; await this.accessService.assertPermissions({ bookingUid, diff --git a/packages/features/booking-audit/lib/service/BookingHistoryViewerService.ts b/packages/features/booking-audit/lib/service/BookingHistoryViewerService.ts new file mode 100644 index 00000000000000..e6d66452ccbe91 --- /dev/null +++ b/packages/features/booking-audit/lib/service/BookingHistoryViewerService.ts @@ -0,0 +1,105 @@ +import type { RoutingFormResponseRepositoryInterface } from "@calcom/lib/server/repository/RoutingFormResponseRepository.interface"; + +import type { BookingAuditViewerService, DisplayBookingAuditLog } from "./BookingAuditViewerService"; +import { getFieldResponseByIdentifier } from "@calcom/features/routing-forms/lib/getFieldResponseByIdentifier"; + +type GetHistoryForBookingParams = { + bookingUid: string; + userId: number; + userEmail: string; + userTimeZone: string; + organizationId: number | null; +}; + +type BookingHistoryLog = DisplayBookingAuditLog; + +interface BookingHistoryViewerServiceDeps { + bookingAuditViewerService: BookingAuditViewerService; + routingFormResponseRepository: RoutingFormResponseRepositoryInterface; +} + +export class BookingHistoryViewerService { + private readonly bookingAuditViewerService: BookingAuditViewerService; + private readonly routingFormResponseRepository: RoutingFormResponseRepositoryInterface; + + constructor(private readonly deps: BookingHistoryViewerServiceDeps) { + this.bookingAuditViewerService = deps.bookingAuditViewerService; + this.routingFormResponseRepository = deps.routingFormResponseRepository; + } + + private sortLogsReverseChronologically(historyLogs: BookingHistoryLog[]): BookingHistoryLog[] { + return historyLogs.sort((a, b) => { + const timestampA = new Date(a.timestamp).getTime(); + const timestampB = new Date(b.timestamp).getTime(); + return timestampB - timestampA; + }); + } + + private async getFormAuditLogsForBooking(bookingUid: string): Promise { + // TODO: Form doesn't have its Audit Logs yet, so we replicate them using the Form Response directly for now. + const formResponse = await this.routingFormResponseRepository.findByBookingUidIncludeForm(bookingUid); + if (!formResponse) { + return []; + } + return [this.createFormSubmissionEntry({ formResponse, bookingUid })]; + } + + async getHistoryForBooking( + params: GetHistoryForBookingParams + ): Promise<{ bookingUid: string; auditLogs: BookingHistoryLog[] }> { + const { bookingUid } = params; + + const { auditLogs: bookingAuditLogs } = await this.bookingAuditViewerService.getAuditLogsForBooking(params); + + const historyEntries: BookingHistoryLog[] = [...bookingAuditLogs, ...await this.getFormAuditLogsForBooking(bookingUid)]; + + const sortedLogs = this.sortLogsReverseChronologically(historyEntries); + + return { + bookingUid, + auditLogs: sortedLogs, + }; + } + + private createFormSubmissionEntry({ + formResponse, + bookingUid, + }: { + formResponse: NonNullable< + Awaited> + >; + bookingUid: string; + }): BookingHistoryLog { + const timestamp = formResponse.createdAt.toISOString(); + + const emailFieldResult = getFieldResponseByIdentifier({ responsePayload: formResponse.response, formFields: formResponse.form.fields, identifier: "email" }); + const emailFieldValueFromResponse = emailFieldResult.success ? emailFieldResult.data : null; + // A valid string can be the email otherwise we assume it is not an email + const submitterEmail = typeof emailFieldValueFromResponse === "string" ? emailFieldValueFromResponse : null; + const uniqueId = `form-submission-${formResponse.id}`; + return { + id: uniqueId, + bookingUid, + type: "RECORD_CREATED", + action: "CREATED", + timestamp, + createdAt: timestamp, + source: "WEBAPP", + operationId: uniqueId, + displayJson: null, + actionDisplayTitle: { key: "form_submitted" }, + displayFields: null, + actor: { + id: `form-submission-actor-${formResponse.id}`, + type: "GUEST", + userUuid: null, + attendeeId: null, + name: null, + createdAt: formResponse.createdAt, + displayName: submitterEmail ? `${submitterEmail}` : "Guest", + displayEmail: submitterEmail || null, + displayAvatar: null, + }, + }; + } +} diff --git a/packages/features/bookings/repositories/AttendeeRepository.ts b/packages/features/bookings/repositories/AttendeeRepository.ts index 8803f1c7e08d0d..7230f415525182 100644 --- a/packages/features/bookings/repositories/AttendeeRepository.ts +++ b/packages/features/bookings/repositories/AttendeeRepository.ts @@ -1,24 +1,39 @@ import type { PrismaClient } from "@calcom/prisma"; + import type { IAttendeeRepository } from "./IAttendeeRepository"; /** * Prisma-based implementation of IAttendeeRepository - * + * * This repository provides methods for looking up attendee information. */ export class AttendeeRepository implements IAttendeeRepository { - constructor(private prismaClient: PrismaClient) {} + constructor(private prismaClient: PrismaClient) {} - async findById(id: number): Promise<{ name: string; email: string } | null> { - const attendee = await this.prismaClient.attendee.findUnique({ - where: { id }, - select: { - name: true, - email: true, - }, - }); + async findById(id: number): Promise<{ name: string; email: string } | null> { + const attendee = await this.prismaClient.attendee.findUnique({ + where: { id }, + select: { + name: true, + email: true, + }, + }); - return attendee; - } -} + return attendee; + } + async findByBookingIdAndSeatReference( + bookingId: number, + seatReferenceUid: string + ): Promise<{ email: string }[]> { + return this.prismaClient.attendee.findMany({ + where: { + bookingId, + bookingSeat: { + referenceUid: seatReferenceUid, + }, + }, + select: { email: true }, + }); + } +} diff --git a/packages/features/flags/features.repository.interface.ts b/packages/features/flags/features.repository.interface.ts index fcbca26d25c886..9a18dc0a7b1fcf 100644 --- a/packages/features/flags/features.repository.interface.ts +++ b/packages/features/flags/features.repository.interface.ts @@ -7,6 +7,7 @@ import type { AppFlags, FeatureState } from "./config"; export interface IFeaturesRepository { checkIfFeatureIsEnabledGlobally(slug: keyof AppFlags): Promise; checkIfUserHasFeature(userId: number, slug: string): Promise; + getUserFeaturesStatus(userId: number, slugs: string[]): Promise>; checkIfUserHasFeatureNonHierarchical(userId: number, slug: string): Promise; checkIfTeamHasFeature(teamId: number, slug: keyof AppFlags): Promise; getTeamsWithFeatureEnabled(slug: keyof AppFlags): Promise; diff --git a/packages/features/flags/features.repository.mock.ts b/packages/features/flags/features.repository.mock.ts index b081076f148b30..eb08314ab27335 100644 --- a/packages/features/flags/features.repository.mock.ts +++ b/packages/features/flags/features.repository.mock.ts @@ -6,6 +6,10 @@ export class MockFeaturesRepository implements IFeaturesRepository { return slug === "mock-feature"; } + async getUserFeaturesStatus(userId: number, slugs: string[]): Promise> { + return Object.fromEntries(slugs.map((slug) => [slug, slug === "mock-feature"])); + } + async checkIfUserHasFeatureNonHierarchical(userId: number, slug: string) { return slug === "mock-feature"; } diff --git a/packages/features/flags/features.repository.ts b/packages/features/flags/features.repository.ts index 7cdced31229e17..76f3651cf8b153 100644 --- a/packages/features/flags/features.repository.ts +++ b/packages/features/flags/features.repository.ts @@ -19,7 +19,7 @@ export class FeaturesRepository implements IFeaturesRepository { // eslint-disable-next-line @typescript-eslint/no-explicit-any private static featuresCache: { data: any[]; expiry: number } | null = null; - constructor(private prismaClient: PrismaClient) {} + constructor(private prismaClient: PrismaClient) { } private clearCache() { FeaturesRepository.featuresCache = null; @@ -151,6 +151,62 @@ export class FeaturesRepository implements IFeaturesRepository { } } + /** + * Checks if a specific user has access to multiple features in a single operation. + * + * @param userId - The ID of the user to check + * @param slugs - Array of feature identifiers to check + * @returns Promise> - A record mapping each slug to its enabled status + * @throws Error if the feature access check fails + */ + async getUserFeaturesStatus(userId: number, slugs: string[]): Promise> { + try { + if (slugs.length === 0) { + return {}; + } + + const featuresStatus: Record = Object.fromEntries(slugs.map((slug) => [slug, false])); + + const userFeatures = await this.prismaClient.userFeatures.findMany({ + where: { + userId, + featureId: { + in: slugs, + }, + }, + select: { + featureId: true, + enabled: true, + }, + }); + + const featuresConfiguredForUser = new Set(); + const slugsToCheckAtTeamLevel: string[] = []; + + for (const userFeature of userFeatures) { + featuresStatus[userFeature.featureId] = userFeature.enabled; + featuresConfiguredForUser.add(userFeature.featureId); + } + + for (const slug of slugs) { + // If the feature is not configured for the user, check team-level + if (!featuresConfiguredForUser.has(slug)) { + slugsToCheckAtTeamLevel.push(slug); + } + } + + await Promise.all(slugsToCheckAtTeamLevel.map(async (slug) => { + const hasTeamFeature = await this.checkIfUserBelongsToTeamWithFeature(userId, slug); + featuresStatus[slug] = hasTeamFeature; + })) + + return featuresStatus; + } catch (err) { + captureException(err); + throw err; + } + } + /** * Checks if a specific user has access to a feature, ignoring hierarchical (parent) teams. * Only checks direct user assignments and direct team memberships — does not traverse parents. diff --git a/packages/features/package.json b/packages/features/package.json index ba376ac260a3e1..2cd6240b475e42 100644 --- a/packages/features/package.json +++ b/packages/features/package.json @@ -27,6 +27,7 @@ "date-fns-tz": "3.2.0", "framer-motion": "10.12.8", "p-limit": "6.2.0", + "react-awesome-query-builder": "5.1.2", "react-select": "5.8.0", "react-sticky-box": "2.0.4", "recharts": "3.0.2", diff --git a/packages/features/routing-forms/di/RoutingFormResponseRepository.module.ts b/packages/features/routing-forms/di/RoutingFormResponseRepository.module.ts new file mode 100644 index 00000000000000..fa1466a0b0a91b --- /dev/null +++ b/packages/features/routing-forms/di/RoutingFormResponseRepository.module.ts @@ -0,0 +1,21 @@ +import { PrismaRoutingFormResponseRepository } from "@calcom/lib/server/repository/PrismaRoutingFormResponseRepository"; +import { ROUTING_FORM_DI_TOKENS } from "@calcom/features/routing-forms/di/tokens"; +import { bindModuleToClassOnToken } from "@calcom/features/di/di"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; +import { createModule } from "../../di/di"; + +export const routingFormResponseRepositoryModule = createModule(); +const token = ROUTING_FORM_DI_TOKENS.ROUTING_FORM_RESPONSE_REPOSITORY; +const moduleToken = ROUTING_FORM_DI_TOKENS.ROUTING_FORM_RESPONSE_REPOSITORY_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: routingFormResponseRepositoryModule, + moduleToken, + token, + classs: PrismaRoutingFormResponseRepository, + dep: prismaModuleLoader, +}); + +export const moduleLoader = { + token, + loadModule, +}; diff --git a/packages/features/routing-forms/di/tokens.ts b/packages/features/routing-forms/di/tokens.ts new file mode 100644 index 00000000000000..fdd070bf4f936e --- /dev/null +++ b/packages/features/routing-forms/di/tokens.ts @@ -0,0 +1,4 @@ +export const ROUTING_FORM_DI_TOKENS = { + ROUTING_FORM_RESPONSE_REPOSITORY: Symbol("RoutingFormResponseRepository"), + ROUTING_FORM_RESPONSE_REPOSITORY_MODULE: Symbol("RoutingFormResponseRepositoryModule"), +}; diff --git a/packages/features/routing-forms/lib/findFieldValueByIdentifier.ts b/packages/features/routing-forms/lib/findFieldValueByIdentifier.ts new file mode 100644 index 00000000000000..1485a17d7e9af8 --- /dev/null +++ b/packages/features/routing-forms/lib/findFieldValueByIdentifier.ts @@ -0,0 +1,21 @@ +import getFieldIdentifier from "./getFieldIdentifier"; +import type { RoutingFormResponseData } from "./types"; + +type FindFieldValueByIdentifierResult = + | { success: true; data: string | string[] | number | null } + | { success: false; error: string }; + +export function findFieldValueByIdentifier( + data: RoutingFormResponseData, + identifier: string +): FindFieldValueByIdentifierResult { + const field = data.fields.find((field) => getFieldIdentifier(field) === identifier); + if (!field) { + return { success: false, error: `Field with identifier ${identifier} not found` }; + } + + const fieldValue = data.response[field.id]?.value; + + return { success: true, data: fieldValue ?? null }; +} + diff --git a/packages/features/routing-forms/lib/getFieldIdentifier.ts b/packages/features/routing-forms/lib/getFieldIdentifier.ts new file mode 100644 index 00000000000000..a7f7332572fb67 --- /dev/null +++ b/packages/features/routing-forms/lib/getFieldIdentifier.ts @@ -0,0 +1,6 @@ +import type { Field } from "./types"; + +const getFieldIdentifier = (field: Field) => field.identifier || field.label; + +export default getFieldIdentifier; + diff --git a/packages/features/routing-forms/lib/getFieldResponseByIdentifier.ts b/packages/features/routing-forms/lib/getFieldResponseByIdentifier.ts new file mode 100644 index 00000000000000..3e26e03b2d655a --- /dev/null +++ b/packages/features/routing-forms/lib/getFieldResponseByIdentifier.ts @@ -0,0 +1,16 @@ +import { parseRoutingFormResponse } from "./parseRoutingFormResponse"; +import { findFieldValueByIdentifier } from "./findFieldValueByIdentifier"; + +export const getFieldResponseByIdentifier = ({ + responsePayload, + formFields, + identifier, +}: { + responsePayload: unknown; + formFields: unknown; + identifier: string; +}) => { + const parsedResponse = parseRoutingFormResponse(responsePayload, formFields); + const emailFieldResult = findFieldValueByIdentifier(parsedResponse, identifier); + return emailFieldResult; +}; \ No newline at end of file diff --git a/packages/features/routing-forms/lib/parseRoutingFormResponse.ts b/packages/features/routing-forms/lib/parseRoutingFormResponse.ts new file mode 100644 index 00000000000000..732231fa0493ee --- /dev/null +++ b/packages/features/routing-forms/lib/parseRoutingFormResponse.ts @@ -0,0 +1,9 @@ +import { zodNonRouterField, routingFormResponseInDbSchema } from "./zod"; +import type { RoutingFormResponseData } from "./types"; + +export function parseRoutingFormResponse(rawResponse: unknown, formFields: unknown): RoutingFormResponseData { + const response = routingFormResponseInDbSchema.parse(rawResponse); + const fields = zodNonRouterField.array().parse(formFields); + return { response, fields }; +} + diff --git a/packages/features/routing-forms/lib/types.ts b/packages/features/routing-forms/lib/types.ts new file mode 100644 index 00000000000000..4664d98beba810 --- /dev/null +++ b/packages/features/routing-forms/lib/types.ts @@ -0,0 +1,22 @@ +import type z from "zod"; + +import type { zodNonRouterField, routingFormResponseInDbSchema } from "./zod"; + +export type FormResponse = Record< + // Field ID + string, + { + value: number | string | string[]; + label: string; + identifier?: string; + } +>; + +export type Field = z.infer; +export type Fields = Field[]; + +export type RoutingFormResponseData = { + fields: z.infer[]; + response: z.infer; +}; + diff --git a/packages/features/routing-forms/lib/zod.ts b/packages/features/routing-forms/lib/zod.ts new file mode 100644 index 00000000000000..012fdddab49143 --- /dev/null +++ b/packages/features/routing-forms/lib/zod.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +export type FieldOption = { + label: string; + id: string | null; +}; + +export type TNonRouterField = { + id: string; + label: string; + identifier?: string; + placeholder?: string; + type: string; + /** @deprecated in favour of `options` */ + selectText?: string; + required?: boolean; + deleted?: boolean; + options?: FieldOption[]; +}; + +// Note: zodNonRouterField is NOT annotated with z.ZodType because it uses .extend() below +// which requires the full ZodObject type to be preserved +export const zodNonRouterField = z.object({ + id: z.string(), + label: z.string(), + identifier: z.string().optional(), + placeholder: z.string().optional(), + type: z.string(), + /** + * @deprecated in favour of `options` + */ + selectText: z.string().optional(), + required: z.boolean().optional(), + deleted: z.boolean().optional(), + options: z + .array( + z.object({ + label: z.string(), + // To keep backwards compatibility with the options generated from legacy selectText, we allow saving null as id + // It helps in differentiating whether the routing logic should consider the option.label as value or option.id as value. + // This is important for legacy routes which has option.label saved in conditions and it must keep matching with the value of the option + id: z.string().or(z.null()), + }) + ) + .optional(), +}); + +// This is different from FormResponse in types.d.ts in that it has label optional. We don't seem to be using label at this point, so we might want to use this only while saving the response when Routing Form is submitted +// Record key is formFieldId +export const routingFormResponseInDbSchema = z.record( + z.object({ + label: z.string().optional(), + value: z.union([z.string(), z.number(), z.array(z.string())]), + }) +); + diff --git a/packages/features/tasker/tasker.ts b/packages/features/tasker/tasker.ts index d66d2650da31c9..c550230a02e96e 100644 --- a/packages/features/tasker/tasker.ts +++ b/packages/features/tasker/tasker.ts @@ -2,6 +2,7 @@ import type { z } from "zod"; import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils"; import type { BookingAuditTaskConsumerPayload } from "@calcom/features/booking-audit/lib/types/bookingAuditTask"; + export type TaskerTypes = "internal" | "redis"; type TaskPayloads = { sendWebhook: string; @@ -38,6 +39,9 @@ type TaskPayloads = { routedEventTypeId?: number | null; }; bookingAudit: BookingAuditTaskConsumerPayload; + sendAwaitingPaymentEmail: z.infer< + typeof import("./tasks/sendAwaitingPaymentEmail").sendAwaitingPaymentEmailPayloadSchema + >; }; export type TaskTypes = keyof TaskPayloads; export type TaskHandler = (payload: string, taskId?: string) => Promise; diff --git a/packages/features/tasker/tasks/index.ts b/packages/features/tasker/tasks/index.ts index f60c5e15c18adf..89b4ac7a809ef8 100644 --- a/packages/features/tasker/tasks/index.ts +++ b/packages/features/tasker/tasks/index.ts @@ -30,6 +30,8 @@ const tasks: Record Promise> = { sendAnalyticsEvent: () => import("./analytics/sendAnalyticsEvent").then((module) => module.sendAnalyticsEvent), executeAIPhoneCall: () => import("./executeAIPhoneCall").then((module) => module.executeAIPhoneCall), + sendAwaitingPaymentEmail: () => + import("./sendAwaitingPaymentEmail").then((module) => module.sendAwaitingPaymentEmail), bookingAudit: () => import("./bookingAudit").then((module) => module.bookingAudit), }; diff --git a/packages/features/tasker/tasks/sendAwaitingPaymentEmail.ts b/packages/features/tasker/tasks/sendAwaitingPaymentEmail.ts new file mode 100644 index 00000000000000..08f6e79c035964 --- /dev/null +++ b/packages/features/tasker/tasks/sendAwaitingPaymentEmail.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; + +import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client"; +import { sendAwaitingPaymentEmailAndSMS } from "@calcom/emails/email-manager"; +import { getBooking } from "@calcom/features/bookings/lib/payment/getBooking"; +import { AttendeeRepository } from "@calcom/features/bookings/repositories/AttendeeRepository"; +import stripe from "@calcom/features/ee/payments/server/stripe"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { PrismaBookingPaymentRepository } from "@calcom/lib/server/repository/PrismaBookingPaymentRepository"; +import prisma from "@calcom/prisma"; + +const log = logger.getSubLogger({ prefix: ["sendAwaitingPaymentEmail"] }); + +export const sendAwaitingPaymentEmailPayloadSchema = z.object({ + bookingId: z.number(), + paymentId: z.number(), + attendeeSeatId: z.string().nullable().optional(), +}); + +export async function sendAwaitingPaymentEmail(payload: string): Promise { + const paymentRepository = new PrismaBookingPaymentRepository(); + + try { + const { bookingId, paymentId, attendeeSeatId } = sendAwaitingPaymentEmailPayloadSchema.parse( + JSON.parse(payload) + ); + + log.debug(`Processing sendAwaitingPaymentEmail task for bookingId ${bookingId}, paymentId ${paymentId}`); + + const { booking, evt, eventType } = await getBooking(bookingId); + + const payment = await paymentRepository.findByIdForAwaitingPaymentEmail(paymentId); + + if (!payment) { + log.warn(`Payment ${paymentId} not found, skipping email`); + return; + } + + if (payment.success || booking.paid) { + log.debug( + `Payment ${paymentId} already succeeded or booking ${bookingId} already paid, skipping email` + ); + return; + } + + // verify stripe payment intent status directly in case of a delayed webhook scenario + if (payment.externalId && payment.app?.slug === "stripe") { + try { + const paymentIntent = await stripe.paymentIntents.retrieve(payment.externalId); + if (paymentIntent.status === "succeeded") { + log.debug( + `Stripe PaymentIntent ${payment.externalId} already succeeded, skipping email (webhook may be delayed)` + ); + return; + } + } catch (error) { + log.warn( + `Could not verify Stripe PaymentIntent status for ${payment.externalId}, continuing with email send`, + safeStringify(error) + ); + } + } + + // filter attendees if this is for a specific seat + let attendeesToEmail = evt.attendees; + if (attendeeSeatId) { + const attendeeRepository = new AttendeeRepository(prisma); + const seatAttendees = await attendeeRepository.findByBookingIdAndSeatReference( + bookingId, + attendeeSeatId + ); + const seatEmails = new Set(seatAttendees.map((a) => (a.email || "").toLowerCase())); + attendeesToEmail = evt.attendees.filter((attendee) => + seatEmails.has((attendee.email || "").toLowerCase()) + ); + + if (attendeesToEmail.length === 0) { + log.warn(`No attendees found for seat ${attendeeSeatId} in booking ${bookingId}, skipping email`); + return; + } + } + + /* + the reason why we use the first attendee's info for the payment link is because: + 1. for regular bookings: the first attendee in the array is typically the booker (the person who made the booking and is responsible for payment) + 2. for seated events: after filtering by attendeeSeatId, there's usually only one attendee anyway + */ + const primaryAttendee = attendeesToEmail[0]; + + if (!primaryAttendee) { + log.warn(`No attendees found for booking ${bookingId}, skipping email`); + return; + } + + await sendAwaitingPaymentEmailAndSMS( + { + ...evt, + attendees: attendeesToEmail, + paymentInfo: { + link: createPaymentLink({ + paymentUid: payment.uid, + name: primaryAttendee.name ?? null, + email: primaryAttendee.email ?? null, + date: booking.startTime.toISOString(), + }), + paymentOption: payment.paymentOption || "ON_BOOKING", + amount: payment.amount, + currency: payment.currency, + }, + }, + eventType.metadata + ); + + log.debug(`Successfully sent awaiting payment email for bookingId ${bookingId}`); + } catch (error) { + log.error( + `Failed to send awaiting payment email`, + safeStringify({ payload, error: error instanceof Error ? error.message : String(error) }) + ); + throw error; + } +} diff --git a/packages/lib/server/repository/BookingPaymentRepository.interface.ts b/packages/lib/server/repository/BookingPaymentRepository.interface.ts index e17cfb2689be86..509cf065151b7c 100644 --- a/packages/lib/server/repository/BookingPaymentRepository.interface.ts +++ b/packages/lib/server/repository/BookingPaymentRepository.interface.ts @@ -1,3 +1,4 @@ +import type { Payment, PaymentOption, Prisma } from "@calcom/prisma/client"; import type { JsonValue } from "@calcom/types/Json"; export interface BookingPaymentWithCredentials { @@ -24,7 +25,19 @@ export interface CreatePaymentData { refunded: boolean; success: boolean; currency: string; - data: Record; + data: Prisma.InputJsonValue; +} + +export interface PaymentForAwaitingEmail { + success: boolean; + externalId: string | null; + uid: string; + paymentOption: PaymentOption | null; + amount: number; + currency: string; + app: { + slug: string | null; + } | null; } export interface IBookingPaymentRepository { @@ -33,5 +46,7 @@ export interface IBookingPaymentRepository { credentialType: string ): Promise; - createPaymentRecord(data: CreatePaymentData): Promise; + createPaymentRecord(data: CreatePaymentData): Promise; + + findByIdForAwaitingPaymentEmail(id: number): Promise; } diff --git a/packages/lib/server/repository/PrismaBookingPaymentRepository.ts b/packages/lib/server/repository/PrismaBookingPaymentRepository.ts index d3a4b64eb67d37..32a7656e74d02d 100644 --- a/packages/lib/server/repository/PrismaBookingPaymentRepository.ts +++ b/packages/lib/server/repository/PrismaBookingPaymentRepository.ts @@ -5,6 +5,7 @@ import type { IBookingPaymentRepository, BookingPaymentWithCredentials, CreatePaymentData, + PaymentForAwaitingEmail, } from "./BookingPaymentRepository.interface"; export class PrismaBookingPaymentRepository implements IBookingPaymentRepository { @@ -45,4 +46,23 @@ export class PrismaBookingPaymentRepository implements IBookingPaymentRepository }); return createdPayment; } + + async findByIdForAwaitingPaymentEmail(id: number): Promise { + return await this.prismaClient.payment.findUnique({ + where: { id }, + select: { + success: true, + externalId: true, + uid: true, + paymentOption: true, + amount: true, + currency: true, + app: { + select: { + slug: true, + }, + }, + }, + }); + } } diff --git a/packages/trpc/server/routers/viewer/bookings/_router.tsx b/packages/trpc/server/routers/viewer/bookings/_router.tsx index 5d6ff61733a285..2b456aceb0dc93 100644 --- a/packages/trpc/server/routers/viewer/bookings/_router.tsx +++ b/packages/trpc/server/routers/viewer/bookings/_router.tsx @@ -6,7 +6,7 @@ import { ZConfirmInputSchema } from "./confirm.schema"; import { ZEditLocationInputSchema } from "./editLocation.schema"; import { ZFindInputSchema } from "./find.schema"; import { ZGetInputSchema } from "./get.schema"; -import { ZGetAuditLogsInputSchema } from "./getAuditLogs.schema"; +import { ZGetBookingHistoryInputSchema } from "./getBookingHistory.schema"; import { ZGetBookingAttendeesInputSchema } from "./getBookingAttendees.schema"; import { ZGetBookingDetailsInputSchema } from "./getBookingDetails.schema"; import { ZInstantBookingInputSchema } from "./getInstantBookingLocation.schema"; @@ -108,10 +108,10 @@ export const bookingsRouter = router({ input, }); }), - getAuditLogs: authedProcedure.input(ZGetAuditLogsInputSchema).query(async ({ input, ctx }) => { - const { getAuditLogsHandler } = await import("./getAuditLogs.handler"); + getBookingHistory: authedProcedure.input(ZGetBookingHistoryInputSchema).query(async ({ input, ctx }) => { + const { getBookingHistoryHandler } = await import("./getBookingHistory.handler"); - return getAuditLogsHandler({ + return getBookingHistoryHandler({ ctx, input, }); diff --git a/packages/trpc/server/routers/viewer/bookings/getAuditLogs.schema.ts b/packages/trpc/server/routers/viewer/bookings/getAuditLogs.schema.ts deleted file mode 100644 index 470892626e38a8..00000000000000 --- a/packages/trpc/server/routers/viewer/bookings/getAuditLogs.schema.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { z } from "zod"; - -export type TGetAuditLogsInputSchema = { - bookingUid: string; -}; - -export const ZGetAuditLogsInputSchema: z.ZodType = z.object({ - bookingUid: z.string(), -}); - diff --git a/packages/trpc/server/routers/viewer/bookings/getAuditLogs.handler.ts b/packages/trpc/server/routers/viewer/bookings/getBookingHistory.handler.ts similarity index 78% rename from packages/trpc/server/routers/viewer/bookings/getAuditLogs.handler.ts rename to packages/trpc/server/routers/viewer/bookings/getBookingHistory.handler.ts index b472b1604853d2..5257c3b2dd54b8 100644 --- a/packages/trpc/server/routers/viewer/bookings/getAuditLogs.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/getBookingHistory.handler.ts @@ -3,19 +3,19 @@ import type { TFunction } from "i18next"; import type { PrismaClient } from "@calcom/prisma/client"; import { TRPCError } from "@trpc/server"; -import { getBookingAuditViewerService } from "@calcom/features/booking-audit/di/BookingAuditViewerService.container"; +import { getBookingHistoryViewerService } from "@calcom/features/booking-audit/di/BookingHistoryViewerService.container"; import { BookingAuditErrorCode, BookingAuditPermissionError } from "@calcom/features/booking-audit/lib/service/BookingAuditAccessService"; import { getTranslation } from "@calcom/lib/server/i18n"; import type { TrpcSessionUser } from "../../../types"; -import type { TGetAuditLogsInputSchema } from "./getAuditLogs.schema"; +import type { TGetBookingHistoryInputSchema } from "./getBookingHistory.schema"; -type GetAuditLogsOptions = { +type GetBookingHistoryOptions = { ctx: { user: NonNullable; prisma: PrismaClient; }; - input: TGetAuditLogsInputSchema; + input: TGetBookingHistoryInputSchema; }; const getErrorMessage = (code: BookingAuditErrorCode, t: TFunction): string => { @@ -35,15 +35,15 @@ const getErrorMessage = (code: BookingAuditErrorCode, t: TFunction): string => { } }; -export const getAuditLogsHandler = async ({ ctx, input }: GetAuditLogsOptions) => { +export const getBookingHistoryHandler = async ({ ctx, input }: GetBookingHistoryOptions) => { const { user } = ctx; const { bookingUid } = input; const t = await getTranslation(user.locale ?? "en", "common"); - const bookingAuditViewerService = getBookingAuditViewerService(); + const bookingHistoryViewerService = getBookingHistoryViewerService(); try { - const result = await bookingAuditViewerService.getAuditLogsForBooking({ + const result = await bookingHistoryViewerService.getHistoryForBooking({ bookingUid, userId: user.id, userEmail: user.email, @@ -62,4 +62,3 @@ export const getAuditLogsHandler = async ({ ctx, input }: GetAuditLogsOptions) = throw error; } }; - diff --git a/packages/trpc/server/routers/viewer/bookings/getBookingHistory.schema.ts b/packages/trpc/server/routers/viewer/bookings/getBookingHistory.schema.ts new file mode 100644 index 00000000000000..e44c481efedcc8 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/getBookingHistory.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export type TGetBookingHistoryInputSchema = { + bookingUid: string; +}; + +export const ZGetBookingHistoryInputSchema: z.ZodType = z.object({ + bookingUid: z.string(), +}); diff --git a/packages/types/environment.d.ts b/packages/types/environment.d.ts index 392e60ce675dfd..12ecfbb09fb42d 100644 --- a/packages/types/environment.d.ts +++ b/packages/types/environment.d.ts @@ -34,6 +34,7 @@ declare namespace NodeJS { readonly STRIPE_TEAM_PRODUCT_ID: `prod_${string}` | undefined; readonly PAYMENT_FEE_PERCENTAGE: number | undefined; readonly PAYMENT_FEE_FIXED: number | undefined; + readonly AWAITING_PAYMENT_EMAIL_DELAY_MINUTES: number | undefined; readonly NEXT_PUBLIC_INTERCOM_APP_ID: string | undefined; readonly NEXT_PUBLIC_POSTHOG_KEY: string | undefined; readonly NEXT_PUBLIC_POSTHOG_HOST: string | undefined; diff --git a/packages/ui/components/segmented-control/SegmentedControl.tsx b/packages/ui/components/segmented-control/SegmentedControl.tsx new file mode 100644 index 00000000000000..0e5d59617ef76a --- /dev/null +++ b/packages/ui/components/segmented-control/SegmentedControl.tsx @@ -0,0 +1,74 @@ +import classNames from "@calcom/ui/classNames"; + +export type SegmentedControlData = T | { value: T; label: string }; + +export interface SegmentedControlProps { + data: SegmentedControlData[]; + value: T; + onChange: (value: T) => void; + disabled?: boolean; + className?: string; + "data-testid"?: string; +} + +const SegmentedControl = function ({ + data, + value, + onChange, + disabled = false, + className, + "data-testid": dataTestId, + ...props +}: SegmentedControlProps) { + const handleChange = (newValue: T) => { + if (!disabled) { + onChange(newValue); + } + }; + + return ( +
+
+ {data.map((item, idx) => { + const itemValue = typeof item === "string" ? item : item.value; + const itemLabel = typeof item === "string" ? item : item.label; + const isActive = value === itemValue; + const inputId = `segmented-control-${itemValue}-${idx}`; + + return ( + + ); + })} +
+
+ ); +}; + +export default SegmentedControl; diff --git a/packages/ui/components/segmented-control/index.ts b/packages/ui/components/segmented-control/index.ts new file mode 100644 index 00000000000000..a620a2f8bd9e1e --- /dev/null +++ b/packages/ui/components/segmented-control/index.ts @@ -0,0 +1,2 @@ +export { default as SegmentedControl } from "./SegmentedControl"; +export type { SegmentedControlProps, SegmentedControlData } from "./SegmentedControl"; diff --git a/packages/ui/package.json b/packages/ui/package.json index 69c509241b2663..c14f58dbf08a21 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -57,7 +57,8 @@ "./components/unpublished-entity": "./components/unpublished-entity/index.ts", "./components/table/TableNew": "./components/table/TableNew.tsx", "./components/app-list-card/AppListCard": "./components/app-list-card/AppListCard.tsx", - "./components/section": "./components/section/index.ts" + "./components/section": "./components/section/index.ts", + "./components/segmented-control": "./components/segmented-control/index.ts" }, "license": "MIT", "scripts": { diff --git a/turbo.json b/turbo.json index ea030b915ee59f..6b9db636d6ef5c 100644 --- a/turbo.json +++ b/turbo.json @@ -4,6 +4,7 @@ "globalEnv": [ "ALLOWED_HOSTNAMES", "ANALYZE", + "AWAITING_PAYMENT_EMAIL_DELAY_MINUTES", "API_KEY_PREFIX", "ATOMS_E2E_API_URL", "ATOMS_E2E_OAUTH_CLIENT_ID", diff --git a/yarn.lock b/yarn.lock index 695f49742d3f8e..704ba70ffc19b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2598,6 +2598,7 @@ __metadata: date-fns-tz: "npm:3.2.0" framer-motion: "npm:10.12.8" p-limit: "npm:6.2.0" + react-awesome-query-builder: "npm:5.1.2" react-select: "npm:5.8.0" react-sticky-box: "npm:2.0.4" recharts: "npm:3.0.2"