Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1db27c0ad | ||
|
|
3a1e04bb42 | ||
|
|
ab37847ea1 | ||
|
|
0a2ddf05ca | ||
|
|
04fc0f1d52 | ||
|
|
b76c4c9dcc | ||
|
|
68b71bfb0a | ||
|
|
763c568797 | ||
|
|
4659d08c07 | ||
|
|
b9c350cc45 | ||
|
|
f63d705523 | ||
|
|
2907c5e696 | ||
|
|
dcb47e31ed | ||
|
|
cd5b75fe2c | ||
|
|
97bdf837a9 | ||
|
|
5663b2ae59 | ||
|
|
02b57eb82e | ||
|
|
b23dfa194e | ||
|
|
2484b6fad2 | ||
|
|
c154bb31b7 | ||
|
|
0cf4ebd544 | ||
|
|
805c4d4c09 | ||
|
|
99330b3117 | ||
|
|
cc90a62090 | ||
|
|
79bcd6dc30 | ||
|
|
c6daa61e5e | ||
|
|
b693b6898a | ||
|
|
fb55bc2dcd | ||
|
|
71bd140fe4 | ||
|
|
4c5608147c | ||
|
|
7d16da5c98 | ||
|
|
0ae6e92558 | ||
|
|
052a38e39b |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
Fix Booker atom verify email dialog infinite re-render when entering verification code
|
||||
Vendored
Vendored
@@ -57,7 +57,7 @@ export const eventTypeSelect = {
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
select: { username: true, id: true },
|
||||
select: { username: true, id: true, timeZone: true },
|
||||
},
|
||||
children: {
|
||||
select: {
|
||||
|
||||
@@ -50,7 +50,7 @@ async function handler(req: NextRequest) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload: OAuthTokenPayload = {
|
||||
const payloadAuthToken: OAuthTokenPayload = {
|
||||
userId: decodedRefreshToken.userId,
|
||||
teamId: decodedRefreshToken.teamId,
|
||||
scope: decodedRefreshToken.scope,
|
||||
@@ -58,11 +58,23 @@ async function handler(req: NextRequest) {
|
||||
clientId: client_id,
|
||||
};
|
||||
|
||||
const access_token = jwt.sign(payload, secretKey, {
|
||||
const payloadRefreshToken: OAuthTokenPayload = {
|
||||
userId: decodedRefreshToken.userId,
|
||||
teamId: decodedRefreshToken.teamId,
|
||||
scope: decodedRefreshToken.scope,
|
||||
token_type: "Refresh Token",
|
||||
clientId: client_id,
|
||||
};
|
||||
|
||||
const access_token = jwt.sign(payloadAuthToken, secretKey, {
|
||||
expiresIn: 1800, // 30 min
|
||||
});
|
||||
|
||||
return NextResponse.json({ access_token }, { status: 200 });
|
||||
const refresh_token = jwt.sign(payloadRefreshToken, secretKey, {
|
||||
expiresIn: 30 * 24 * 60 * 60, // 30 days
|
||||
});
|
||||
|
||||
return NextResponse.json({ access_token, refresh_token }, { status: 200 });
|
||||
}
|
||||
|
||||
export const POST = defaultResponderForAppDir(handler);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository
|
||||
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
|
||||
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
@@ -20,6 +21,7 @@ const checkoutSessionMetadataSchema = z.object({
|
||||
teamName: z.string(),
|
||||
teamSlug: z.string(),
|
||||
userId: z.string().transform(Number),
|
||||
isOnboarding: z.string().optional(),
|
||||
});
|
||||
|
||||
const generateRandomString = () => {
|
||||
@@ -66,6 +68,7 @@ async function getHandler(req: NextRequest) {
|
||||
teamName: checkoutSession?.metadata?.teamName ?? generateRandomString(),
|
||||
teamSlug: checkoutSession?.metadata?.teamSlug ?? generateRandomString(),
|
||||
userId: checkoutSession.metadata.userId,
|
||||
isOnboarding: checkoutSession.metadata.isOnboarding,
|
||||
};
|
||||
|
||||
const team = await prisma.team.create({
|
||||
@@ -102,6 +105,16 @@ async function getHandler(req: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Check if this is from onboarding flow and redirect accordingly
|
||||
const isOnboarding = checkoutSessionMetadata.isOnboarding === "true";
|
||||
|
||||
if (isOnboarding) {
|
||||
// Redirect to event-types for onboarding flow
|
||||
return NextResponse.redirect(new URL("/onboarding/personal/settings", WEBAPP_URL), {
|
||||
status: 302,
|
||||
});
|
||||
}
|
||||
|
||||
// redirect to team screen
|
||||
return NextResponse.redirect(
|
||||
new URL(`/settings/teams/${team.id}/onboard-members?event=team_created`, req.nextUrl.origin),
|
||||
|
||||
+12
-9
@@ -125,16 +125,19 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
style={
|
||||
isEmbed
|
||||
? {
|
||||
background: "transparent",
|
||||
// Keep the embed hidden till parent initializes and
|
||||
// - gives it the appropriate styles if UI instruction is there.
|
||||
// - gives iframe the appropriate height(equal to document height) which can only be known after loading the page once in browser.
|
||||
// - Tells iframe which mode it should be in (dark/light) - if there is a a UI instruction for that
|
||||
visibility: "hidden",
|
||||
}
|
||||
background: "transparent",
|
||||
// Keep the embed hidden till parent initializes and
|
||||
// - gives it the appropriate styles if UI instruction is there.
|
||||
// - gives iframe the appropriate height(equal to document height) which can only be known after loading the page once in browser.
|
||||
// - Tells iframe which mode it should be in (dark/light) - if there is a a UI instruction for that
|
||||
visibility: "hidden",
|
||||
// This in addition to visibility: hidden is to ensure that elements with specific opacity set are not visible
|
||||
opacity: 0,
|
||||
}
|
||||
: {
|
||||
visibility: "visible",
|
||||
}
|
||||
visibility: "visible",
|
||||
opacity: 1,
|
||||
}
|
||||
}>
|
||||
<IconSprites />
|
||||
<SpeculationRules
|
||||
|
||||
@@ -90,7 +90,7 @@ export const AlbyPaymentComponent = (props: IAlbyPaymentComponentProps) => {
|
||||
<p className="text-sm">Click or scan the invoice below to pay</p>
|
||||
<Link
|
||||
href={`lightning:${paymentRequest}`}
|
||||
className="inline-flex items-center justify-center rounded-2xl rounded-md border border-transparent bg-white p-2 font-medium text-black shadow-sm hover:brightness-95 focus:outline-none focus:ring-offset-2">
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-transparent bg-white p-2 font-medium text-black shadow-sm hover:brightness-95 focus:outline-none focus:ring-offset-2">
|
||||
<QRCode size={192} value={paymentRequest} />
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
import { useState } from "react";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { MeetingSessionDetailsDialog } from "@calcom/features/ee/video/MeetingSessionDetailsDialog";
|
||||
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
} from "@calcom/ui/components/dropdown";
|
||||
import { TextAreaField } from "@calcom/ui/components/form";
|
||||
import type { ActionType } from "@calcom/ui/components/table";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog";
|
||||
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import { ReassignDialog } from "@components/dialog/ReassignDialog";
|
||||
import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
|
||||
import { RerouteDialog } from "@components/dialog/RerouteDialog";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
|
||||
import { useBookingActionsStoreContext } from "./BookingActionsStoreProvider";
|
||||
import {
|
||||
getCancelEventAction,
|
||||
getEditEventActions,
|
||||
getAfterEventActions,
|
||||
getReportAction,
|
||||
shouldShowEditActions,
|
||||
type BookingActionContext,
|
||||
} from "./bookingActions";
|
||||
import type { BookingItemProps } from "./types";
|
||||
|
||||
interface BookingActionsDropdownProps {
|
||||
booking: BookingItemProps;
|
||||
}
|
||||
|
||||
export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Use store for all dialog states
|
||||
const rejectionReason = useBookingActionsStoreContext((state) => state.rejectionReason);
|
||||
const setRejectionReason = useBookingActionsStoreContext((state) => state.setRejectionReason);
|
||||
const rejectionDialogIsOpen = useBookingActionsStoreContext((state) => state.rejectionDialogIsOpen);
|
||||
const setRejectionDialogIsOpen = useBookingActionsStoreContext((state) => state.setRejectionDialogIsOpen);
|
||||
const chargeCardDialogIsOpen = useBookingActionsStoreContext((state) => state.chargeCardDialogIsOpen);
|
||||
const setChargeCardDialogIsOpen = useBookingActionsStoreContext((state) => state.setChargeCardDialogIsOpen);
|
||||
const viewRecordingsDialogIsOpen = useBookingActionsStoreContext(
|
||||
(state) => state.viewRecordingsDialogIsOpen
|
||||
);
|
||||
const setViewRecordingsDialogIsOpen = useBookingActionsStoreContext(
|
||||
(state) => state.setViewRecordingsDialogIsOpen
|
||||
);
|
||||
const meetingSessionDetailsDialogIsOpen = useBookingActionsStoreContext(
|
||||
(state) => state.meetingSessionDetailsDialogIsOpen
|
||||
);
|
||||
const setMeetingSessionDetailsDialogIsOpen = useBookingActionsStoreContext(
|
||||
(state) => state.setMeetingSessionDetailsDialogIsOpen
|
||||
);
|
||||
const isNoShowDialogOpen = useBookingActionsStoreContext((state) => state.isNoShowDialogOpen);
|
||||
const setIsNoShowDialogOpen = useBookingActionsStoreContext((state) => state.setIsNoShowDialogOpen);
|
||||
const isOpenRescheduleDialog = useBookingActionsStoreContext((state) => state.isOpenRescheduleDialog);
|
||||
const setIsOpenRescheduleDialog = useBookingActionsStoreContext((state) => state.setIsOpenRescheduleDialog);
|
||||
const isOpenReassignDialog = useBookingActionsStoreContext((state) => state.isOpenReassignDialog);
|
||||
const setIsOpenReassignDialog = useBookingActionsStoreContext((state) => state.setIsOpenReassignDialog);
|
||||
const isOpenSetLocationDialog = useBookingActionsStoreContext((state) => state.isOpenSetLocationDialog);
|
||||
const setIsOpenLocationDialog = useBookingActionsStoreContext((state) => state.setIsOpenLocationDialog);
|
||||
const isOpenAddGuestsDialog = useBookingActionsStoreContext((state) => state.isOpenAddGuestsDialog);
|
||||
const setIsOpenAddGuestsDialog = useBookingActionsStoreContext((state) => state.setIsOpenAddGuestsDialog);
|
||||
const isOpenReportDialog = useBookingActionsStoreContext((state) => state.isOpenReportDialog);
|
||||
const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog);
|
||||
const rerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.rerouteDialogIsOpen);
|
||||
const setRerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.setRerouteDialogIsOpen);
|
||||
|
||||
const cardCharged = booking?.payment[0]?.success;
|
||||
|
||||
const attendeeList = booking.attendees.map((attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
id: attendee.id,
|
||||
noShow: attendee.noShow || false,
|
||||
phoneNumber: attendee.phoneNumber,
|
||||
};
|
||||
});
|
||||
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(data.message, "success");
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data?.status === "REJECTED") {
|
||||
setRejectionDialogIsOpen(false);
|
||||
showToast(t("booking_rejection_success"), "success");
|
||||
} else {
|
||||
showToast(t("booking_confirmation_success"), "success");
|
||||
}
|
||||
utils.viewer.bookings.invalidate();
|
||||
utils.viewer.me.bookingUnconfirmedCount.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("booking_confirmation_failed"), "error");
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("location_updated"), "success");
|
||||
setIsOpenLocationDialog(false);
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (e) => {
|
||||
const errorMessages: Record<string, string> = {
|
||||
UNAUTHORIZED: t("you_are_unauthorized_to_make_this_change_to_the_booking"),
|
||||
BAD_REQUEST: e.message,
|
||||
};
|
||||
|
||||
const message = errorMessages[e.data?.code as string] || t("location_update_failed");
|
||||
showToast(message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||
const isOngoing = isUpcoming && new Date() >= new Date(booking.startTime);
|
||||
const isBookingInPast = new Date(booking.endTime) < new Date();
|
||||
const isCancelled = booking.status === "CANCELLED";
|
||||
const isConfirmed = booking.status === "ACCEPTED";
|
||||
const isRejected = booking.status === "REJECTED";
|
||||
const isPending = booking.status === "PENDING";
|
||||
const isRescheduled = booking.fromReschedule !== null;
|
||||
const isRecurring = booking.recurringEventId !== null;
|
||||
|
||||
const getBookingStatus = (): "upcoming" | "past" | "cancelled" | "rejected" => {
|
||||
if (isCancelled) return "cancelled";
|
||||
if (isRejected) return "rejected";
|
||||
if (isBookingInPast) return "past";
|
||||
return "upcoming";
|
||||
};
|
||||
|
||||
const isTabRecurring = booking.listingStatus === "recurring";
|
||||
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
||||
|
||||
const isBookingFromRoutingForm = !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team;
|
||||
|
||||
const userEmail = booking.loggedInUser.userEmail;
|
||||
const userSeat = booking.seatsReferences.find((seat) => !!userEmail && seat.attendee?.email === userEmail);
|
||||
const isAttendee = !!userSeat;
|
||||
|
||||
const isCalVideoLocation =
|
||||
!booking.location ||
|
||||
booking.location === "integrations:daily" ||
|
||||
(typeof booking.location === "string" && booking.location.trim() === "");
|
||||
|
||||
const isDisabledCancelling = booking.eventType.disableCancelling;
|
||||
const isDisabledRescheduling = booking.eventType.disableRescheduling;
|
||||
|
||||
const getSeatReferenceUid = () => {
|
||||
return userSeat?.referenceUid;
|
||||
};
|
||||
|
||||
const bookingConfirm = async (confirm: boolean) => {
|
||||
let body = {
|
||||
bookingId: booking.id,
|
||||
confirmed: confirm,
|
||||
reason: rejectionReason,
|
||||
};
|
||||
|
||||
if ((isTabRecurring || isTabUnconfirmed) && isRecurring) {
|
||||
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
|
||||
}
|
||||
mutation.mutate(body);
|
||||
};
|
||||
|
||||
const saveLocation = async ({
|
||||
newLocation,
|
||||
credentialId,
|
||||
}: {
|
||||
newLocation: string;
|
||||
credentialId: number | null;
|
||||
}) => {
|
||||
try {
|
||||
await setLocationMutation.mutateAsync({
|
||||
bookingId: booking.id,
|
||||
newLocation,
|
||||
credentialId,
|
||||
});
|
||||
} catch {
|
||||
// Errors are shown through the mutation onError handler
|
||||
}
|
||||
};
|
||||
|
||||
const actionContext: BookingActionContext = {
|
||||
booking,
|
||||
isUpcoming,
|
||||
isOngoing,
|
||||
isBookingInPast,
|
||||
isCancelled,
|
||||
isConfirmed,
|
||||
isRejected,
|
||||
isPending,
|
||||
isRescheduled,
|
||||
isRecurring,
|
||||
isTabRecurring,
|
||||
isTabUnconfirmed,
|
||||
isBookingFromRoutingForm,
|
||||
isDisabledCancelling,
|
||||
isDisabledRescheduling,
|
||||
isCalVideoLocation,
|
||||
showPendingPayment: false, // This will be calculated below
|
||||
isAttendee,
|
||||
cardCharged,
|
||||
attendeeList,
|
||||
getSeatReferenceUid,
|
||||
t,
|
||||
} as BookingActionContext;
|
||||
|
||||
const cancelEventAction = getCancelEventAction(actionContext);
|
||||
|
||||
const baseEditEventActions = getEditEventActions(actionContext);
|
||||
const editEventActions: ActionType[] = baseEditEventActions.map((action) => ({
|
||||
...action,
|
||||
onClick:
|
||||
action.id === "reschedule_request"
|
||||
? () => setIsOpenRescheduleDialog(true)
|
||||
: action.id === "reroute"
|
||||
? () => setRerouteDialogIsOpen(true)
|
||||
: action.id === "change_location"
|
||||
? () => setIsOpenLocationDialog(true)
|
||||
: action.id === "add_members"
|
||||
? () => setIsOpenAddGuestsDialog(true)
|
||||
: action.id === "reassign"
|
||||
? () => setIsOpenReassignDialog(true)
|
||||
: undefined,
|
||||
})) as ActionType[];
|
||||
|
||||
const baseAfterEventActions = getAfterEventActions(actionContext);
|
||||
const afterEventActions: ActionType[] = baseAfterEventActions.map((action) => ({
|
||||
...action,
|
||||
onClick:
|
||||
action.id === "view_recordings"
|
||||
? () => setViewRecordingsDialogIsOpen(true)
|
||||
: action.id === "meeting_session_details"
|
||||
? () => setMeetingSessionDetailsDialogIsOpen(true)
|
||||
: action.id === "charge_card"
|
||||
? () => setChargeCardDialogIsOpen(true)
|
||||
: action.id === "no_show"
|
||||
? () => {
|
||||
if (attendeeList.length === 1) {
|
||||
const attendee = attendeeList[0];
|
||||
noShowMutation.mutate({
|
||||
bookingUid: booking.uid,
|
||||
attendees: [{ email: attendee.email, noShow: !attendee.noShow }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsNoShowDialogOpen(true);
|
||||
}
|
||||
: undefined,
|
||||
disabled:
|
||||
action.disabled ||
|
||||
(action.id === "no_show" && !(isBookingInPast || isOngoing)) ||
|
||||
(action.id === "view_recordings" && !booking.isRecorded),
|
||||
})) as ActionType[];
|
||||
|
||||
const reportAction = getReportAction(actionContext);
|
||||
const reportActionWithHandler = {
|
||||
...reportAction,
|
||||
onClick: () => setIsOpenReportDialog(true),
|
||||
};
|
||||
|
||||
const NoShowAttendeesDialog = ({
|
||||
bookingUid,
|
||||
attendees,
|
||||
setIsOpen,
|
||||
isOpen,
|
||||
}: {
|
||||
bookingUid: string;
|
||||
attendees: Array<{
|
||||
name: string;
|
||||
email: string;
|
||||
id: number;
|
||||
noShow: boolean;
|
||||
phoneNumber: string | null;
|
||||
}>;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
isOpen: boolean;
|
||||
}) => {
|
||||
const [noShowAttendees, setNoShowAttendees] = useState<
|
||||
Array<{
|
||||
email: string;
|
||||
noShow: boolean;
|
||||
}>
|
||||
>(attendees.map((attendee) => ({ email: attendee.email, noShow: attendee.noShow })));
|
||||
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(data.message, "success");
|
||||
setIsOpen(false);
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent title={t("mark_as_no_show")}>
|
||||
<div className="space-y-2">
|
||||
{attendees.map((attendee, index) => (
|
||||
<label key={attendee.email} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={noShowAttendees[index]?.noShow || false}
|
||||
onChange={(e) => {
|
||||
const newNoShowAttendees = [...noShowAttendees];
|
||||
newNoShowAttendees[index] = {
|
||||
email: attendee.email,
|
||||
noShow: e.target.checked,
|
||||
};
|
||||
setNoShowAttendees(newNoShowAttendees);
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{attendee.name} ({attendee.email})
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
onClick={() => {
|
||||
noShowMutation.mutate({
|
||||
bookingUid,
|
||||
attendees: noShowAttendees,
|
||||
});
|
||||
}}>
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const parsedBooking = {
|
||||
...booking,
|
||||
eventType: booking.eventType.id ? booking.eventType : null,
|
||||
};
|
||||
|
||||
// Render dialogs that might be triggered from BookingListItem even if dropdown is not shown
|
||||
const dialogs = (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
isOpenDialog={isOpenRescheduleDialog}
|
||||
setIsOpenDialog={setIsOpenRescheduleDialog}
|
||||
bookingUId={booking.uid}
|
||||
/>
|
||||
{isOpenReassignDialog && (
|
||||
<ReassignDialog
|
||||
isOpenDialog={isOpenReassignDialog}
|
||||
setIsOpenDialog={setIsOpenReassignDialog}
|
||||
bookingId={booking.id}
|
||||
teamId={booking.eventType?.team?.id || 0}
|
||||
bookingFromRoutingForm={isBookingFromRoutingForm}
|
||||
/>
|
||||
)}
|
||||
<EditLocationDialog
|
||||
booking={booking}
|
||||
saveLocation={saveLocation}
|
||||
isOpenDialog={isOpenSetLocationDialog}
|
||||
setShowLocationModal={setIsOpenLocationDialog}
|
||||
teamId={booking.eventType?.team?.id}
|
||||
/>
|
||||
<AddGuestsDialog
|
||||
isOpenDialog={isOpenAddGuestsDialog}
|
||||
setIsOpenDialog={setIsOpenAddGuestsDialog}
|
||||
bookingId={booking.id}
|
||||
/>
|
||||
<ReportBookingDialog
|
||||
isOpenDialog={isOpenReportDialog}
|
||||
setIsOpenDialog={setIsOpenReportDialog}
|
||||
bookingUid={booking.uid}
|
||||
isRecurring={isRecurring}
|
||||
status={getBookingStatus()}
|
||||
/>
|
||||
{booking.paid && booking.payment[0] && (
|
||||
<ChargeCardDialog
|
||||
isOpenDialog={chargeCardDialogIsOpen}
|
||||
setIsOpenDialog={setChargeCardDialogIsOpen}
|
||||
bookingId={booking.id}
|
||||
paymentAmount={booking.payment[0].amount}
|
||||
paymentCurrency={booking.payment[0].currency}
|
||||
/>
|
||||
)}
|
||||
{isCalVideoLocation && (
|
||||
<ViewRecordingsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={viewRecordingsDialogIsOpen}
|
||||
setIsOpenDialog={setViewRecordingsDialogIsOpen}
|
||||
timeFormat={booking.loggedInUser.userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{isCalVideoLocation && meetingSessionDetailsDialogIsOpen && (
|
||||
<MeetingSessionDetailsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={meetingSessionDetailsDialogIsOpen}
|
||||
setIsOpenDialog={setMeetingSessionDetailsDialogIsOpen}
|
||||
timeFormat={booking.loggedInUser.userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{isNoShowDialogOpen && (
|
||||
<NoShowAttendeesDialog
|
||||
bookingUid={booking.uid}
|
||||
attendees={attendeeList}
|
||||
setIsOpen={setIsNoShowDialogOpen}
|
||||
isOpen={isNoShowDialogOpen}
|
||||
/>
|
||||
)}
|
||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
|
||||
<div>
|
||||
<TextAreaField
|
||||
name="rejectionReason"
|
||||
label={
|
||||
<>
|
||||
{t("rejection_reason")}
|
||||
<span className="text-subtle font-normal"> (Optional)</span>
|
||||
</>
|
||||
}
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
disabled={mutation.isPending}
|
||||
data-testid="rejection-confirm"
|
||||
onClick={() => {
|
||||
bookingConfirm(false);
|
||||
}}>
|
||||
{t("rejection_confirmation")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{isBookingFromRoutingForm &&
|
||||
parsedBooking.eventType &&
|
||||
parsedBooking.eventType.id !== undefined &&
|
||||
parsedBooking.eventType.slug !== undefined &&
|
||||
parsedBooking.eventType.title !== undefined &&
|
||||
parsedBooking.routedFromRoutingFormReponse && (
|
||||
<RerouteDialog
|
||||
isOpenDialog={rerouteDialogIsOpen}
|
||||
setIsOpenDialog={setRerouteDialogIsOpen}
|
||||
booking={{
|
||||
...parsedBooking,
|
||||
metadata: parsedBooking.metadata as z.infer<typeof bookingMetadataSchema>,
|
||||
routedFromRoutingFormReponse: parsedBooking.routedFromRoutingFormReponse,
|
||||
eventType: {
|
||||
length: parsedBooking.eventType.length ?? 0,
|
||||
schedulingType: parsedBooking.eventType.schedulingType ?? null,
|
||||
title: parsedBooking.eventType.title,
|
||||
id: parsedBooking.eventType.id,
|
||||
slug: parsedBooking.eventType.slug,
|
||||
team: parsedBooking.eventType.team ?? null,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Don't render dropdown if edit actions shouldn't be shown
|
||||
if (!shouldShowEditActions(actionContext)) {
|
||||
return dialogs;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{dialogs}
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon="ellipsis"
|
||||
data-testid="booking-actions-dropdown"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("edit_event")}</DropdownMenuLabel>
|
||||
{editEventActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("after_event")}</DropdownMenuLabel>
|
||||
{afterEventActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
key={reportActionWithHandler.id}
|
||||
disabled={reportActionWithHandler.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={reportActionWithHandler.color}
|
||||
StartIcon={reportActionWithHandler.icon}
|
||||
onClick={reportActionWithHandler.onClick}
|
||||
disabled={reportActionWithHandler.disabled}
|
||||
data-testid={reportActionWithHandler.id}
|
||||
className={reportActionWithHandler.disabled ? "text-muted" : undefined}>
|
||||
{reportActionWithHandler.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
key={cancelEventAction.id}
|
||||
disabled={cancelEventAction.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={cancelEventAction.color}
|
||||
StartIcon={cancelEventAction.icon}
|
||||
href={cancelEventAction.disabled ? undefined : cancelEventAction.href}
|
||||
onClick={cancelEventAction.onClick}
|
||||
disabled={cancelEventAction.disabled}
|
||||
data-bookingid={cancelEventAction.bookingId}
|
||||
data-testid={cancelEventAction.id}
|
||||
className={cancelEventAction.disabled ? "text-muted" : undefined}>
|
||||
{cancelEventAction.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useRef, type ReactNode } from "react";
|
||||
import { useStore } from "zustand";
|
||||
import type { StoreApi } from "zustand";
|
||||
|
||||
import { createBookingActionsStore, type BookingActionsStore } from "./store";
|
||||
|
||||
export const BookingActionsStoreContext = createContext<StoreApi<BookingActionsStore> | null>(null);
|
||||
|
||||
export interface BookingActionsStoreProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const BookingActionsStoreProvider = ({ children }: BookingActionsStoreProviderProps) => {
|
||||
const storeRef = useRef<StoreApi<BookingActionsStore>>();
|
||||
if (!storeRef.current) {
|
||||
storeRef.current = createBookingActionsStore();
|
||||
}
|
||||
|
||||
return (
|
||||
<BookingActionsStoreContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</BookingActionsStoreContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useBookingActionsStoreContext = <T,>(
|
||||
selector: (store: BookingActionsStore) => T,
|
||||
equalityFn?: (a: T, b: T) => boolean
|
||||
): T => {
|
||||
const bookingActionsStoreContext = useContext(BookingActionsStoreContext);
|
||||
|
||||
if (!bookingActionsStoreContext) {
|
||||
throw new Error("useBookingActionsStoreContext must be used within BookingActionsStoreProvider");
|
||||
}
|
||||
|
||||
return useStore(bookingActionsStoreContext, selector, equalityFn);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
@@ -9,8 +10,6 @@ import dayjs from "@calcom/dayjs";
|
||||
// TODO: Use browser locale, implement Intl in Dayjs maybe?
|
||||
import "@calcom/dayjs/locales";
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { MeetingSessionDetailsDialog } from "@calcom/features/ee/video/MeetingSessionDetailsDialog";
|
||||
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
|
||||
import { formatTime } from "@calcom/lib/dayjs";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -20,7 +19,6 @@ import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import type { AssignmentReason } from "@calcom/prisma/client";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { Ensure } from "@calcom/types/utils";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
@@ -48,42 +46,18 @@ import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
|
||||
|
||||
import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog";
|
||||
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import { ReassignDialog } from "@components/dialog/ReassignDialog";
|
||||
import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
|
||||
import { RerouteDialog } from "@components/dialog/RerouteDialog";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
|
||||
import { BookingActionsDropdown } from "./BookingActionsDropdown";
|
||||
import { useBookingActionsStoreContext, BookingActionsStoreProvider } from "./BookingActionsStoreProvider";
|
||||
import {
|
||||
getPendingActions,
|
||||
getCancelEventAction,
|
||||
getEditEventActions,
|
||||
getAfterEventActions,
|
||||
getReportAction,
|
||||
shouldShowPendingActions,
|
||||
shouldShowEditActions,
|
||||
shouldShowRecurringCancelAction,
|
||||
shouldShowIndividualReportButton,
|
||||
type BookingActionContext,
|
||||
getReportAction,
|
||||
} from "./bookingActions";
|
||||
|
||||
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
|
||||
|
||||
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||
|
||||
export type BookingItemProps = BookingItem & {
|
||||
listingStatus: BookingListingStatus;
|
||||
recurringInfo: RouterOutputs["viewer"]["bookings"]["get"]["recurringInfo"][number] | undefined;
|
||||
loggedInUser: {
|
||||
userId: number | undefined;
|
||||
userTimeZone: string | undefined;
|
||||
userTimeFormat: number | null | undefined;
|
||||
userEmail: string | undefined;
|
||||
};
|
||||
isToday: boolean;
|
||||
};
|
||||
import type { BookingItemProps } from "./types";
|
||||
|
||||
type ParsedBooking = ReturnType<typeof buildParsedBooking>;
|
||||
type TeamEvent = Ensure<NonNullable<ParsedBooking["eventType"]>, "team">;
|
||||
@@ -131,11 +105,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
|
||||
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
|
||||
const [meetingSessionDetailsDialogIsOpen, setMeetingSessionDetailsDialogIsOpen] = useState<boolean>(false);
|
||||
const [isNoShowDialogOpen, setIsNoShowDialogOpen] = useState<boolean>(false);
|
||||
const cardCharged = booking?.payment[0]?.success;
|
||||
|
||||
const attendeeList = booking.attendees.map((attendee) => {
|
||||
return {
|
||||
@@ -147,17 +116,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
};
|
||||
});
|
||||
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(data.message, "success");
|
||||
// Invalidate and refetch the bookings query to update the UI
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data?.status === BookingStatus.REJECTED) {
|
||||
@@ -185,12 +143,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const isRescheduled = booking.fromReschedule !== null;
|
||||
const isRecurring = booking.recurringEventId !== null;
|
||||
|
||||
const getBookingStatus = (): "upcoming" | "past" | "cancelled" | "rejected" => {
|
||||
if (isCancelled) return "cancelled";
|
||||
if (isRejected) return "rejected";
|
||||
if (isBookingInPast) return "past";
|
||||
return "upcoming";
|
||||
};
|
||||
const isTabRecurring = booking.listingStatus === "recurring";
|
||||
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
||||
const isBookingFromRoutingForm = isBookingReroutable(parsedBooking);
|
||||
@@ -219,6 +171,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
|
||||
const isDisabledCancelling = booking.eventType.disableCancelling;
|
||||
const isDisabledRescheduling = booking.eventType.disableRescheduling;
|
||||
const cardCharged = booking?.payment[0]?.success;
|
||||
|
||||
const bookingConfirm = async (confirm: boolean) => {
|
||||
let body = {
|
||||
@@ -298,49 +251,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
.tz(userTimeZone)
|
||||
.locale(language)
|
||||
.format(isUpcoming ? (isDifferentYear ? "ddd, D MMM YYYY" : "ddd, D MMM") : "D MMMM YYYY");
|
||||
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
||||
const [isOpenReassignDialog, setIsOpenReassignDialog] = useState(false);
|
||||
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
|
||||
const [isOpenAddGuestsDialog, setIsOpenAddGuestsDialog] = useState(false);
|
||||
const [isOpenReportDialog, setIsOpenReportDialog] = useState(false);
|
||||
const [rerouteDialogIsOpen, setRerouteDialogIsOpen] = useState(false);
|
||||
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("location_updated"), "success");
|
||||
setIsOpenLocationDialog(false);
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (e) => {
|
||||
const errorMessages: Record<string, string> = {
|
||||
UNAUTHORIZED: t("you_are_unauthorized_to_make_this_change_to_the_booking"),
|
||||
BAD_REQUEST: e.message,
|
||||
};
|
||||
|
||||
const message = errorMessages[e.data?.code as string] || t("location_update_failed");
|
||||
showToast(message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const saveLocation = async ({
|
||||
newLocation,
|
||||
credentialId,
|
||||
}: {
|
||||
newLocation: string;
|
||||
/**
|
||||
* It could be set for conferencing locations that support team level installations.
|
||||
*/
|
||||
credentialId: number | null;
|
||||
}) => {
|
||||
try {
|
||||
await setLocationMutation.mutateAsync({
|
||||
bookingId: booking.id,
|
||||
newLocation,
|
||||
credentialId,
|
||||
});
|
||||
} catch {
|
||||
// Errors are shown through the mutation onError handler
|
||||
}
|
||||
};
|
||||
|
||||
// Getting accepted recurring dates to show
|
||||
const recurringDates = booking.recurringInfo?.bookings[BookingStatus.ACCEPTED]
|
||||
@@ -360,58 +270,9 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
|
||||
const title = booking.title;
|
||||
|
||||
const isCalVideoLocation =
|
||||
!booking.location ||
|
||||
booking.location === "integrations:daily" ||
|
||||
(typeof booking.location === "string" && booking.location.trim() === "");
|
||||
|
||||
const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid;
|
||||
|
||||
const baseEditEventActions = getEditEventActions(actionContext);
|
||||
const editEventActions: ActionType[] = baseEditEventActions.map((action) => ({
|
||||
...action,
|
||||
onClick:
|
||||
action.id === "reschedule_request"
|
||||
? () => setIsOpenRescheduleDialog(true)
|
||||
: action.id === "reroute"
|
||||
? () => setRerouteDialogIsOpen(true)
|
||||
: action.id === "change_location"
|
||||
? () => setIsOpenLocationDialog(true)
|
||||
: action.id === "add_members"
|
||||
? () => setIsOpenAddGuestsDialog(true)
|
||||
: action.id === "reassign"
|
||||
? () => setIsOpenReassignDialog(true)
|
||||
: undefined,
|
||||
})) as ActionType[];
|
||||
|
||||
const baseAfterEventActions = getAfterEventActions(actionContext);
|
||||
const afterEventActions: ActionType[] = baseAfterEventActions.map((action) => ({
|
||||
...action,
|
||||
onClick:
|
||||
action.id === "view_recordings"
|
||||
? () => setViewRecordingsDialogIsOpen(true)
|
||||
: action.id === "meeting_session_details"
|
||||
? () => setMeetingSessionDetailsDialogIsOpen(true)
|
||||
: action.id === "charge_card"
|
||||
? () => setChargeCardDialogIsOpen(true)
|
||||
: action.id === "no_show"
|
||||
? () => {
|
||||
if (attendeeList.length === 1) {
|
||||
const attendee = attendeeList[0];
|
||||
noShowMutation.mutate({
|
||||
bookingUid: booking.uid,
|
||||
attendees: [{ email: attendee.email, noShow: !attendee.noShow }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsNoShowDialogOpen(true);
|
||||
}
|
||||
: undefined,
|
||||
disabled:
|
||||
action.disabled ||
|
||||
(action.id === "no_show" && !(isBookingInPast || isOngoing)) ||
|
||||
(action.id === "view_recordings" && !booking.isRecorded),
|
||||
})) as ActionType[];
|
||||
const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog);
|
||||
|
||||
const reportAction = getReportAction(actionContext);
|
||||
const reportActionWithHandler = {
|
||||
@@ -421,72 +282,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
isOpenDialog={isOpenRescheduleDialog}
|
||||
setIsOpenDialog={setIsOpenRescheduleDialog}
|
||||
bookingUId={booking.uid}
|
||||
/>
|
||||
{isOpenReassignDialog && (
|
||||
<ReassignDialog
|
||||
isOpenDialog={isOpenReassignDialog}
|
||||
setIsOpenDialog={setIsOpenReassignDialog}
|
||||
bookingId={booking.id}
|
||||
teamId={booking.eventType?.team?.id || 0}
|
||||
bookingFromRoutingForm={isBookingFromRoutingForm}
|
||||
/>
|
||||
)}
|
||||
<EditLocationDialog
|
||||
booking={booking}
|
||||
saveLocation={saveLocation}
|
||||
isOpenDialog={isOpenSetLocationDialog}
|
||||
setShowLocationModal={setIsOpenLocationDialog}
|
||||
teamId={booking.eventType?.team?.id}
|
||||
/>
|
||||
<AddGuestsDialog
|
||||
isOpenDialog={isOpenAddGuestsDialog}
|
||||
setIsOpenDialog={setIsOpenAddGuestsDialog}
|
||||
bookingId={booking.id}
|
||||
/>
|
||||
<ReportBookingDialog
|
||||
isOpenDialog={isOpenReportDialog}
|
||||
setIsOpenDialog={setIsOpenReportDialog}
|
||||
bookingUid={booking.uid}
|
||||
isRecurring={isRecurring}
|
||||
status={getBookingStatus()}
|
||||
/>
|
||||
{booking.paid && booking.payment[0] && (
|
||||
<ChargeCardDialog
|
||||
isOpenDialog={chargeCardDialogIsOpen}
|
||||
setIsOpenDialog={setChargeCardDialogIsOpen}
|
||||
bookingId={booking.id}
|
||||
paymentAmount={booking.payment[0].amount}
|
||||
paymentCurrency={booking.payment[0].currency}
|
||||
/>
|
||||
)}
|
||||
{isCalVideoLocation && (
|
||||
<ViewRecordingsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={viewRecordingsDialogIsOpen}
|
||||
setIsOpenDialog={setViewRecordingsDialogIsOpen}
|
||||
timeFormat={userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{isCalVideoLocation && meetingSessionDetailsDialogIsOpen && (
|
||||
<MeetingSessionDetailsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={meetingSessionDetailsDialogIsOpen}
|
||||
setIsOpenDialog={setMeetingSessionDetailsDialogIsOpen}
|
||||
timeFormat={userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{isNoShowDialogOpen && (
|
||||
<NoShowAttendeesDialog
|
||||
bookingUid={booking.uid}
|
||||
attendees={attendeeList}
|
||||
setIsOpen={setIsNoShowDialogOpen}
|
||||
isOpen={isNoShowDialogOpen}
|
||||
/>
|
||||
)}
|
||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
|
||||
<div>
|
||||
@@ -555,8 +350,10 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
className="text-sm leading-6 text-blue-600 hover:underline dark:text-blue-400">
|
||||
<div className="flex items-center gap-2">
|
||||
{provider?.iconUrl && (
|
||||
<img
|
||||
<Image
|
||||
src={provider.iconUrl}
|
||||
width={16}
|
||||
height={16}
|
||||
className="h-4 w-4 rounded-sm"
|
||||
alt={`${provider?.label} logo`}
|
||||
/>
|
||||
@@ -661,94 +458,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</div>
|
||||
<div className="flex w-full flex-col flex-wrap items-end justify-end space-x-2 space-y-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:space-y-0 sm:pl-0">
|
||||
{shouldShowPendingActions(actionContext) && <TableActions actions={pendingActions} />}
|
||||
{shouldShowEditActions(actionContext) && (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon="ellipsis"
|
||||
data-testid="booking-actions-dropdown"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("edit_event")}</DropdownMenuLabel>
|
||||
{editEventActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("after_event")}</DropdownMenuLabel>
|
||||
{afterEventActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
key={reportActionWithHandler.id}
|
||||
disabled={reportActionWithHandler.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={reportActionWithHandler.color}
|
||||
StartIcon={reportActionWithHandler.icon}
|
||||
onClick={reportActionWithHandler.onClick}
|
||||
disabled={reportActionWithHandler.disabled}
|
||||
data-testid={reportActionWithHandler.id}
|
||||
className={reportActionWithHandler.disabled ? "text-muted" : undefined}>
|
||||
{reportActionWithHandler.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
key={cancelEventAction.id}
|
||||
disabled={cancelEventAction.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={cancelEventAction.color}
|
||||
StartIcon={cancelEventAction.icon}
|
||||
href={cancelEventAction.disabled ? undefined : cancelEventAction.href}
|
||||
onClick={cancelEventAction.onClick}
|
||||
disabled={cancelEventAction.disabled}
|
||||
data-bookingid={cancelEventAction.bookingId}
|
||||
data-testid={cancelEventAction.id}
|
||||
className={cancelEventAction.disabled ? "text-muted" : undefined}>
|
||||
{cancelEventAction.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
)}
|
||||
<BookingActionsDropdown booking={booking} />
|
||||
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
|
||||
{shouldShowIndividualReportButton(actionContext) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -782,14 +492,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
isRescheduled={isRescheduled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isBookingFromRoutingForm && (
|
||||
<RerouteDialog
|
||||
isOpenDialog={rerouteDialogIsOpen}
|
||||
setIsOpenDialog={setRerouteDialogIsOpen}
|
||||
booking={{ ...parsedBooking, eventType: parsedBooking.eventType }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -903,7 +605,7 @@ const RecurringBookingsTooltip = ({
|
||||
|
||||
return (
|
||||
(booking.recurringInfo &&
|
||||
booking.eventType?.recurringEvent?.freq &&
|
||||
booking.eventType?.recurringEvent?.freq != null &&
|
||||
(booking.listingStatus === "recurring" ||
|
||||
booking.listingStatus === "unconfirmed" ||
|
||||
booking.listingStatus === "cancelled") && (
|
||||
@@ -1174,79 +876,6 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const NoShowAttendeesDialog = ({
|
||||
attendees,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
bookingUid,
|
||||
}: {
|
||||
attendees: AttendeeProps[];
|
||||
isOpen: boolean;
|
||||
setIsOpen: (value: boolean) => void;
|
||||
bookingUid: string;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const [noShowAttendees, setNoShowAttendees] = useState(
|
||||
attendees.map((attendee) => ({
|
||||
id: attendee.id,
|
||||
email: attendee.email,
|
||||
name: attendee.name,
|
||||
noShow: attendee.noShow || false,
|
||||
}))
|
||||
);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
const newValue = data.attendees[0];
|
||||
setNoShowAttendees((old) =>
|
||||
old.map((attendee) =>
|
||||
attendee.email === newValue.email ? { ...attendee, noShow: newValue.noShow } : attendee
|
||||
)
|
||||
);
|
||||
showToast(t(data.message), "success");
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={() => setIsOpen(false)}>
|
||||
<DialogContent title={t("mark_as_no_show_title")} description={t("no_show_description")} enableOverflow>
|
||||
{noShowAttendees.map((attendee, index) => (
|
||||
<form
|
||||
key={attendee.id}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
noShowMutation.mutate({
|
||||
bookingUid,
|
||||
attendees: [{ email: attendee.email, noShow: !attendee.noShow }],
|
||||
});
|
||||
}}>
|
||||
<div
|
||||
className={`bg-muted flex items-center justify-between px-4 py-2 ${
|
||||
index === 0 ? "rounded-t-md" : ""
|
||||
} ${index === noShowAttendees.length - 1 ? "rounded-b-md" : "border-subtle border-b"}`}>
|
||||
<span className="text-emphasis flex flex-col text-sm">
|
||||
{attendee.name}
|
||||
{attendee.email && <span className="text-muted">({attendee.email})</span>}
|
||||
</span>
|
||||
<Button color="minimal" type="submit" StartIcon={attendee.noShow ? "eye-off" : "eye"}>
|
||||
{attendee.noShow ? t("unmark_as_no_show") : t("mark_as_no_show")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
))}
|
||||
<DialogFooter noSticky>
|
||||
<DialogClose>{t("done")}</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const { t } = useLocale();
|
||||
@@ -1369,4 +998,13 @@ const AssignmentReasonTooltip = ({ assignmentReason }: { assignmentReason: Assig
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingListItem;
|
||||
// Wrap BookingListItem with BookingActionsStoreProvider to provide isolated store for each booking
|
||||
const BookingListItemWithProvider = (props: BookingItemProps) => {
|
||||
return (
|
||||
<BookingActionsStoreProvider>
|
||||
<BookingListItem {...props} />
|
||||
</BookingActionsStoreProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingListItemWithProvider;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { ActionType } from "@calcom/ui/components/table";
|
||||
|
||||
import type { BookingItemProps } from "./BookingListItem";
|
||||
import type { BookingItemProps } from "./types";
|
||||
|
||||
export interface BookingActionContext {
|
||||
booking: BookingItemProps;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { createStore } from "zustand";
|
||||
|
||||
export type BookingActionsStore = {
|
||||
// Dialog states
|
||||
rejectionDialogIsOpen: boolean;
|
||||
chargeCardDialogIsOpen: boolean;
|
||||
viewRecordingsDialogIsOpen: boolean;
|
||||
meetingSessionDetailsDialogIsOpen: boolean;
|
||||
isNoShowDialogOpen: boolean;
|
||||
isOpenRescheduleDialog: boolean;
|
||||
isOpenReassignDialog: boolean;
|
||||
isOpenSetLocationDialog: boolean;
|
||||
isOpenAddGuestsDialog: boolean;
|
||||
isOpenReportDialog: boolean;
|
||||
rerouteDialogIsOpen: boolean;
|
||||
|
||||
// Dialog setters
|
||||
setRejectionDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setChargeCardDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setViewRecordingsDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setMeetingSessionDetailsDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsNoShowDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsOpenRescheduleDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsOpenReassignDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsOpenLocationDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsOpenAddGuestsDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsOpenReportDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setRerouteDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
// Rejection reason state
|
||||
rejectionReason: string;
|
||||
setRejectionReason: (reason: string) => void;
|
||||
};
|
||||
|
||||
export const createBookingActionsStore = () => {
|
||||
return createStore<BookingActionsStore>((set) => ({
|
||||
// Initial dialog states
|
||||
rejectionDialogIsOpen: false,
|
||||
chargeCardDialogIsOpen: false,
|
||||
viewRecordingsDialogIsOpen: false,
|
||||
meetingSessionDetailsDialogIsOpen: false,
|
||||
isNoShowDialogOpen: false,
|
||||
isOpenRescheduleDialog: false,
|
||||
isOpenReassignDialog: false,
|
||||
isOpenSetLocationDialog: false,
|
||||
isOpenAddGuestsDialog: false,
|
||||
isOpenReportDialog: false,
|
||||
rerouteDialogIsOpen: false,
|
||||
|
||||
// Dialog setters
|
||||
setRejectionDialogIsOpen: (isOpen) =>
|
||||
set((state) => ({
|
||||
rejectionDialogIsOpen: typeof isOpen === "function" ? isOpen(state.rejectionDialogIsOpen) : isOpen,
|
||||
})),
|
||||
setChargeCardDialogIsOpen: (isOpen) =>
|
||||
set((state) => ({
|
||||
chargeCardDialogIsOpen: typeof isOpen === "function" ? isOpen(state.chargeCardDialogIsOpen) : isOpen,
|
||||
})),
|
||||
setViewRecordingsDialogIsOpen: (isOpen) =>
|
||||
set((state) => ({
|
||||
viewRecordingsDialogIsOpen:
|
||||
typeof isOpen === "function" ? isOpen(state.viewRecordingsDialogIsOpen) : isOpen,
|
||||
})),
|
||||
setMeetingSessionDetailsDialogIsOpen: (isOpen) =>
|
||||
set((state) => ({
|
||||
meetingSessionDetailsDialogIsOpen:
|
||||
typeof isOpen === "function" ? isOpen(state.meetingSessionDetailsDialogIsOpen) : isOpen,
|
||||
})),
|
||||
setIsNoShowDialogOpen: (isOpen) =>
|
||||
set((state) => ({
|
||||
isNoShowDialogOpen: typeof isOpen === "function" ? isOpen(state.isNoShowDialogOpen) : isOpen,
|
||||
})),
|
||||
setIsOpenRescheduleDialog: (isOpen) =>
|
||||
set((state) => ({
|
||||
isOpenRescheduleDialog: typeof isOpen === "function" ? isOpen(state.isOpenRescheduleDialog) : isOpen,
|
||||
})),
|
||||
setIsOpenReassignDialog: (isOpen) =>
|
||||
set((state) => ({
|
||||
isOpenReassignDialog: typeof isOpen === "function" ? isOpen(state.isOpenReassignDialog) : isOpen,
|
||||
})),
|
||||
setIsOpenLocationDialog: (isOpen) =>
|
||||
set((state) => ({
|
||||
isOpenSetLocationDialog:
|
||||
typeof isOpen === "function" ? isOpen(state.isOpenSetLocationDialog) : isOpen,
|
||||
})),
|
||||
setIsOpenAddGuestsDialog: (isOpen) =>
|
||||
set((state) => ({
|
||||
isOpenAddGuestsDialog: typeof isOpen === "function" ? isOpen(state.isOpenAddGuestsDialog) : isOpen,
|
||||
})),
|
||||
setIsOpenReportDialog: (isOpen) =>
|
||||
set((state) => ({
|
||||
isOpenReportDialog: typeof isOpen === "function" ? isOpen(state.isOpenReportDialog) : isOpen,
|
||||
})),
|
||||
setRerouteDialogIsOpen: (isOpen) =>
|
||||
set((state) => ({
|
||||
rerouteDialogIsOpen: typeof isOpen === "function" ? isOpen(state.rerouteDialogIsOpen) : isOpen,
|
||||
})),
|
||||
|
||||
// Rejection reason state
|
||||
rejectionReason: "",
|
||||
setRejectionReason: (reason: string) => set({ rejectionReason: reason }),
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
|
||||
|
||||
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||
|
||||
export type BookingItemProps = BookingItem & {
|
||||
listingStatus: BookingListingStatus;
|
||||
recurringInfo: RouterOutputs["viewer"]["bookings"]["get"]["recurringInfo"][number] | undefined;
|
||||
loggedInUser: {
|
||||
userId: number | undefined;
|
||||
userTimeZone: string | undefined;
|
||||
userTimeFormat: number | null | undefined;
|
||||
userEmail: string | undefined;
|
||||
};
|
||||
isToday: boolean;
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/types/server/routers/_app";
|
||||
@@ -81,7 +82,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
// Use the current username or if it's not set, use the one available from stripe
|
||||
setInputUsernameValue(currentUsername || stripeCustomer?.username || "");
|
||||
setInputUsernameValue(slugify(currentUsername || stripeCustomer?.username || "", true));
|
||||
}, [setInputUsernameValue, currentUsername, stripeCustomer?.username]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -105,7 +106,8 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
const updateUsername = trpc.viewer.me.updateProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
onSuccessMutation && (await onSuccessMutation());
|
||||
await update({ username: inputUsernameValue });
|
||||
const sanitizedUsername = slugify(inputUsernameValue || "");
|
||||
await update({ username: sanitizedUsername });
|
||||
setOpenDialogSaveUsername(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -171,11 +173,12 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
};
|
||||
|
||||
const saveUsername = () => {
|
||||
const sanitizedUsername = slugify(inputUsernameValue || "");
|
||||
if (usernameChangeCondition !== UsernameChangeStatusEnum.UPGRADE) {
|
||||
updateUsername.mutate({
|
||||
username: inputUsernameValue,
|
||||
username: sanitizedUsername,
|
||||
});
|
||||
setCurrentUsername(inputUsernameValue);
|
||||
setCurrentUsername(sanitizedUsername);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -234,7 +237,8 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
if (searchParams?.toString() !== _searchParams.toString()) {
|
||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||
}
|
||||
setInputUsernameValue(event.target.value);
|
||||
const sanitized = slugify(event.target.value, true);
|
||||
setInputUsernameValue(sanitized);
|
||||
}}
|
||||
data-testid="username-input"
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/
|
||||
import { TextField } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
|
||||
import type { TRPCClientErrorLike } from "@trpc/client";
|
||||
|
||||
@@ -72,10 +73,11 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
||||
|
||||
const updateUsernameMutation = trpc.viewer.me.updateProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
const sanitizedUsername = slugify(inputUsernameValue || "");
|
||||
onSuccessMutation && (await onSuccessMutation());
|
||||
setOpenDialogSaveUsername(false);
|
||||
setCurrentUsername(inputUsernameValue);
|
||||
await update({ username: inputUsernameValue });
|
||||
setCurrentUsername(sanitizedUsername);
|
||||
await update({ username: sanitizedUsername });
|
||||
},
|
||||
onError: (error) => {
|
||||
onErrorMutation && onErrorMutation(error);
|
||||
@@ -108,8 +110,9 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
||||
};
|
||||
|
||||
const updateUsername = async () => {
|
||||
const sanitizedUsername = slugify(inputUsernameValue || "");
|
||||
updateUsernameMutation.mutate({
|
||||
username: inputUsernameValue,
|
||||
username: sanitizedUsername,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -134,7 +137,8 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
||||
)}
|
||||
onChange={(event) => {
|
||||
event.preventDefault();
|
||||
setInputUsernameValue(event.target.value);
|
||||
const sanitized = slugify(event.target.value, true);
|
||||
setInputUsernameValue(sanitized);
|
||||
}}
|
||||
data-testid="username-input"
|
||||
{...rest}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { WEBSITE_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/types/server/routers/_app";
|
||||
|
||||
@@ -56,7 +57,7 @@ export const UsernameAvailabilityField = ({
|
||||
: { username: currentUsernameState || "", setQuery: setCurrentUsernameState };
|
||||
const formMethods = useForm({
|
||||
defaultValues: {
|
||||
username: currentUsername,
|
||||
username: slugify(currentUsername || user.username || ""),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -78,7 +79,11 @@ export const UsernameAvailabilityField = ({
|
||||
setCurrentUsername={setCurrentUsername}
|
||||
inputUsernameValue={value}
|
||||
usernameRef={ref}
|
||||
setInputUsernameValue={onChange}
|
||||
setInputUsernameValue={(val) => {
|
||||
const displayValue = slugify(val, true);
|
||||
formMethods.setValue("username", displayValue);
|
||||
onChange?.(displayValue);
|
||||
}}
|
||||
onSuccessMutation={onSuccessMutation}
|
||||
onErrorMutation={onErrorMutation}
|
||||
disabled={!!user.organization?.id}
|
||||
|
||||
@@ -580,14 +580,16 @@ export default function Success(props: PageProps) {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isCancelled && bookingInfo?.cancelledBy && (
|
||||
<>
|
||||
<div className="font-medium">{t("cancelled_by")}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">
|
||||
<p className="break-words">{bookingInfo?.cancelledBy}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isCancelled &&
|
||||
bookingInfo?.cancelledBy &&
|
||||
!(bookingInfo.eventType?.hideOrganizerEmail && !isHost) && (
|
||||
<>
|
||||
<div className="font-medium">{t("cancelled_by")}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">
|
||||
<p className="break-words">{bookingInfo?.cancelledBy}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{previousBooking && (
|
||||
<>
|
||||
<div className="font-medium">{t("rescheduled_by")}</div>
|
||||
@@ -743,7 +745,7 @@ export default function Success(props: PageProps) {
|
||||
{showUtmParams && (
|
||||
<div className="col-span-2 mb-2 mt-2">
|
||||
{Object.entries(utmParams).filter(([_, value]) => Boolean(value)).length >
|
||||
0 ? (
|
||||
0 ? (
|
||||
<ul className="list-disc space-y-1 p-1 pl-5 sm:w-80">
|
||||
{Object.entries(utmParams)
|
||||
.filter(([_, value]) => Boolean(value))
|
||||
@@ -851,10 +853,11 @@ export default function Success(props: PageProps) {
|
||||
<span className="text-default inline">
|
||||
<span className="underline" data-testid="reschedule-link">
|
||||
<Link
|
||||
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}${currentUserEmail
|
||||
? `?rescheduledBy=${encodeURIComponent(currentUserEmail)}`
|
||||
: ""
|
||||
}`}
|
||||
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}${
|
||||
currentUserEmail
|
||||
? `?rescheduledBy=${encodeURIComponent(currentUserEmail)}`
|
||||
: ""
|
||||
}`}
|
||||
legacyBehavior>
|
||||
{t("reschedule")}
|
||||
</Link>
|
||||
|
||||
@@ -24,6 +24,7 @@ export function useCreateTeam() {
|
||||
name: teamDetails.name,
|
||||
slug: teamDetails.slug,
|
||||
logo: teamBrand.logo,
|
||||
isOnboarding: true,
|
||||
});
|
||||
|
||||
// If there's a checkout URL, redirect to Stripe payment
|
||||
@@ -35,7 +36,7 @@ export function useCreateTeam() {
|
||||
if (result.team) {
|
||||
// Not sure we need this flag check - keeping it here for safe keeping as this is called only from v3 onboarding flow
|
||||
const gettingStartedPath = flags["onboarding-v3"]
|
||||
? "/onboarding/getting-started"
|
||||
? "/onboarding/personal/settings"
|
||||
: "/getting-started";
|
||||
router.push(gettingStartedPath);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { setShowNewOrgModalFlag } from "@calcom/features/ee/organizations/hooks/useWelcomeModal";
|
||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -25,14 +26,8 @@ export const useSubmitOnboarding = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const {
|
||||
selectedPlan,
|
||||
organizationDetails,
|
||||
organizationBrand,
|
||||
teams,
|
||||
inviteRole,
|
||||
resetOnboarding,
|
||||
} = store;
|
||||
const { selectedPlan, organizationDetails, organizationBrand, teams, inviteRole, resetOnboarding } =
|
||||
store;
|
||||
|
||||
if (selectedPlan !== "organization") {
|
||||
throw new Error("Only organization plan is currently supported");
|
||||
@@ -83,7 +78,8 @@ export const useSubmitOnboarding = () => {
|
||||
// No checkout URL means billing is disabled (self-hosted flow)
|
||||
// Organization has already been created by the backend
|
||||
showToast("Organization created successfully!", "success");
|
||||
// TODO: after this redirect we need to hard refresh the page to see org
|
||||
// Set flag to show welcome modal after personal onboarding redirect
|
||||
setShowNewOrgModalFlag();
|
||||
skipToPersonal(resetOnboarding);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to create organization";
|
||||
|
||||
@@ -188,14 +188,22 @@ export const OrganizationInviteView = ({ userEmail }: OrganizationInviteViewProp
|
||||
<Form form={form} handleSubmit={handleContinue} className="w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{/* Email and Team inputs */}
|
||||
<div className="flex flex-col">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Label className="text-emphasis text-sm font-medium">{t("email")}</Label>
|
||||
<Label className="text-emphasis text-sm font-medium">{t("team")}</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-2">
|
||||
<Label
|
||||
className="text-emphasis mb-0 text-sm font-medium"
|
||||
htmlFor="invites.0.email">
|
||||
{t("email")}
|
||||
</Label>
|
||||
<Label
|
||||
className="text-emphasis mb-0 text-sm font-medium"
|
||||
htmlFor="invites.0.team">
|
||||
{t("team")}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-0.5 pt-2 md:pt-0">
|
||||
<div key={field.id} className="flex items-start gap-0.5">
|
||||
<div className="grid flex-1 items-start gap-2 md:grid-cols-2 ">
|
||||
<TextField
|
||||
labelSrOnly
|
||||
|
||||
@@ -113,7 +113,6 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
|
||||
form.setValue("name", e.target.value);
|
||||
}}
|
||||
placeholder="John Doe"
|
||||
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="text-error text-sm">{form.formState.errors.name.message}</p>
|
||||
@@ -128,7 +127,6 @@ export const PersonalSettingsView = ({ userEmail, userName }: PersonalSettingsVi
|
||||
<TimezoneSelect
|
||||
value={selectedTimeZone}
|
||||
onChange={({ value }) => setSelectedTimeZone(value)}
|
||||
size="sm"
|
||||
/>
|
||||
<p className="text-subtle text-xs font-normal leading-3">
|
||||
{t("current_time")}{" "}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
import { MailIcon } from "./MailIcon";
|
||||
|
||||
type CompanyEmailOrganizationBannerProps = {
|
||||
onDismissAction: () => void;
|
||||
};
|
||||
|
||||
export const CompanyEmailOrganizationBanner = ({ onDismissAction }: CompanyEmailOrganizationBannerProps) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const flags = useFlagMap();
|
||||
|
||||
const handleLearnMore = () => {
|
||||
const redirectPath = flags["onboarding-v3"]
|
||||
? "/onboarding/organization/details"
|
||||
: "/settings/organizations/new";
|
||||
router.push(redirectPath);
|
||||
};
|
||||
|
||||
if (!flags["onboarding-v3"]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-subtle from-muted relative mb-6 overflow-hidden rounded-lg border bg-gradient-to-r to-transparent p-4">
|
||||
{/* Dot grid background with fade */}
|
||||
<svg className="pointer-events-none absolute inset-0 h-full w-full" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
{/* Dot pattern - 12px spacing */}
|
||||
<pattern id="banner-dot-pattern" x="0" y="0" width="12" height="12" patternUnits="userSpaceOnUse">
|
||||
<circle cx="1" cy="1" r="0.8" fill="currentColor" className="text-subtle" opacity="0.3" />
|
||||
</pattern>
|
||||
{/* Fade mask from left to right */}
|
||||
<linearGradient id="banner-fade-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="white" stopOpacity="1" />
|
||||
<stop offset="10%" stopColor="white" stopOpacity="0.4" />
|
||||
<stop offset="100%" stopColor="white" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<mask id="banner-fade-mask">
|
||||
<rect x="0" y="0" width="100%" height="100%" fill="url(#banner-fade-gradient)" />
|
||||
</mask>
|
||||
</defs>
|
||||
{/* Apply pattern with mask */}
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="url(#banner-dot-pattern)"
|
||||
mask="url(#banner-fade-mask)"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="relative z-10 flex gap-4">
|
||||
<div>
|
||||
<MailIcon />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col">
|
||||
<h3 className="text-default text-sm font-semibold">
|
||||
{t("it_appears_you_are_using_company_email")}
|
||||
</h3>
|
||||
<p className="text-default text-sm">{t("explore_organizational_plan_description")}</p>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button color="secondary" onClick={onDismissAction}>
|
||||
{t("dismiss")}
|
||||
</Button>
|
||||
<Button color="primary" EndIcon="external-link" onClick={handleLearnMore}>
|
||||
{t("upgrade")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
@@ -12,6 +12,7 @@ import { z } from "zod";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { DisplayInfo } from "@calcom/features/users/components/UserTable/EditSheet/DisplayInfo";
|
||||
@@ -46,6 +47,8 @@ import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
|
||||
|
||||
import type { TRPCClientErrorLike } from "@trpc/client";
|
||||
|
||||
import { CompanyEmailOrganizationBanner } from "./components/CompanyEmailOrganizationBanner";
|
||||
|
||||
interface DeleteAccountValues {
|
||||
totpCode: string;
|
||||
}
|
||||
@@ -72,7 +75,8 @@ type Props = {
|
||||
const ProfileView = ({ user }: Props) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const { update } = useSession();
|
||||
const session = useSession();
|
||||
const { update } = session;
|
||||
const updateProfileMutation = trpc.viewer.me.updateProfile.useMutation({
|
||||
onSuccess: async (res) => {
|
||||
await update(res);
|
||||
@@ -138,6 +142,7 @@ const ProfileView = ({ user }: Props) => {
|
||||
const [deleteAccountOpen, setDeleteAccountOpen] = useState(false);
|
||||
const [hasDeleteErrors, setHasDeleteErrors] = useState(false);
|
||||
const [deleteErrorMessage, setDeleteErrorMessage] = useState("");
|
||||
const [isCompanyEmailAlertDismissed, setIsCompanyEmailAlertDismissed] = useState(false);
|
||||
const form = useForm<DeleteAccountValues>();
|
||||
|
||||
const onDeleteMeSuccessMutation = async () => {
|
||||
@@ -250,6 +255,14 @@ const ProfileView = ({ user }: Props) => {
|
||||
],
|
||||
};
|
||||
|
||||
// Check if user should see company email alert
|
||||
const shouldShowCompanyEmailAlert =
|
||||
!isCompanyEmailAlertDismissed &&
|
||||
!session.data?.user?.org?.id &&
|
||||
!user.organization?.id &&
|
||||
userEmail &&
|
||||
isCompanyEmail(userEmail);
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
title={t("profile")}
|
||||
@@ -301,6 +314,12 @@ const ProfileView = ({ user }: Props) => {
|
||||
isCALIdentityProvider={isCALIdentityProvider}
|
||||
/>
|
||||
|
||||
{shouldShowCompanyEmailAlert && (
|
||||
<div className="mt-6">
|
||||
<CompanyEmailOrganizationBanner onDismissAction={() => setIsCompanyEmailAlertDismissed(true)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-subtle mt-6 rounded-lg rounded-b-none border border-b-0 p-6">
|
||||
<Label className="mb-0 text-base font-semibold text-red-700">{t("danger_zone")}</Label>
|
||||
<p className="text-subtle text-sm">{t("account_deletion_cannot_be_undone")}</p>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { setShowNewOrgModalFlag } from "@calcom/features/ee/organizations/hooks/useWelcomeModal";
|
||||
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc";
|
||||
@@ -52,7 +53,9 @@ const useOrgCreation = () => {
|
||||
await utils.viewer.organizations.listCurrent.invalidate();
|
||||
await session.update();
|
||||
reset();
|
||||
window.location.href = `${window.location.origin}/settings/organizations/profile`;
|
||||
// Set flag to show welcome modal (using both query param and sessionStorage for reliability)
|
||||
setShowNewOrgModalFlag();
|
||||
window.location.href = `${window.location.origin}/settings/organizations/profile?newOrganizationModal=true`;
|
||||
} else {
|
||||
// Unexpected state
|
||||
setServerErrorMessage("Unexpected response from server");
|
||||
|
||||
@@ -28,7 +28,7 @@ const PaymentStatusView = () => {
|
||||
if (organization) {
|
||||
setOrganizationCreated(true);
|
||||
// Organization is created, redirect to next step
|
||||
router.push(`/settings/organizations`);
|
||||
router.push(`/settings/organizations?newOrganizationModal=true`);
|
||||
}
|
||||
}, [organization, router, useOnboardingStore]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.8.7",
|
||||
"version": "5.8.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 87 KiB |
@@ -836,8 +836,6 @@
|
||||
"workflow_validation_failed": "Workflow validation failed",
|
||||
"workflow_validation_empty_fields": "One or more workflow steps have empty message content",
|
||||
"workflow_validation_unverified_contacts": "One or more phone numbers or email addresses are not verified",
|
||||
"supercharge_your_workflows_with_cal_ai": "Supercharge your Workflows with Cal.ai",
|
||||
"supercharge_your_workflows_with_cal_ai_description": "Lifelike AI agents that book meetings, send reminders, and follow up with your customers.",
|
||||
"phone_number_imported_successfully": "Phone number imported and linked to agent successfully",
|
||||
"phone_number_deleted_successfully": "Phone number deleted successfully",
|
||||
"delete_phone_number_confirmation": "Are you sure you want to delete this phone number? This action cannot be undone.",
|
||||
@@ -1692,6 +1690,8 @@
|
||||
"profile_picture": "Profile Picture",
|
||||
"upload": "Upload",
|
||||
"add_profile_photo": "Add profile photo",
|
||||
"it_appears_you_are_using_company_email": "It appears that you are utilizing your company email.",
|
||||
"explore_organizational_plan_description": "Explore our new organizational plan! It boosts collaboration across event types and directs lost customers to the right team member.",
|
||||
"token_address": "Token Address",
|
||||
"blockchain": "Blockchain",
|
||||
"old_password": "Old password",
|
||||
@@ -2494,6 +2494,7 @@
|
||||
"additional_url_parameters": "Additional URL parameters",
|
||||
"about_your_organization": "About your organization",
|
||||
"about_your_organization_description": "Organizations are shared environments where you can create multiple teams with shared members, event types, apps, workflows and more.",
|
||||
"create_your_team": "Create your team",
|
||||
"create_your_teams": "Create your teams",
|
||||
"create_your_teams_description": "Start scheduling together by adding your team members to your organization",
|
||||
"invite_organization_admins": "Invite organization members",
|
||||
@@ -2527,6 +2528,15 @@
|
||||
"resolve": "Resolve",
|
||||
"no_organization_slug": "There was an error creating teams for this organization. Missing URL slug.",
|
||||
"copy_link_org": "Copy link to organization",
|
||||
"welcome_to_organizations": "Welcome to Organizations!",
|
||||
"organizations_welcome_description": "We're excited to connect your bookers with your team members. With your Teams's plan you get:",
|
||||
"1_parent_team_unlimited_subteams": "1 parent team and unlimited sub-teams",
|
||||
"organization_workflows": "Organization workflows",
|
||||
"custom_subdomain": "Yourcompany.cal.com subdomain",
|
||||
"instant_meetings": "Cal.com instant meetings",
|
||||
"collective_round_robin_events": "Collective & Round-robin events",
|
||||
"routing_forms": "Routing forms",
|
||||
"team_workflows": "Team workflows",
|
||||
"404_the_org": "The organization",
|
||||
"404_the_team": "The team",
|
||||
"404_claim_entity_org": "Claim your subdomain for your organization",
|
||||
@@ -3906,13 +3916,18 @@
|
||||
"view_previous_week": "View previous week",
|
||||
"view_next_week": "View next week",
|
||||
"invite_teammates": "Invite Teammates",
|
||||
"invite_team_members": "Invite team members",
|
||||
"organization_brand": "Organization Brand",
|
||||
"organization_details": "Organization Details",
|
||||
"organization_teams": "Organization Teams",
|
||||
"personal_profile": "Personal Profile",
|
||||
"personal_settings": "Personal Settings",
|
||||
"customize_team_brand": "Customize your team brand",
|
||||
"team_brand": "Team Brand",
|
||||
"team_brand_subtitle": "Customize your team's branding with a logo and color",
|
||||
"team_details": "Team Details",
|
||||
"team_invite": "Team Invite",
|
||||
"team_invite_subtitle": "Invite team members to collaborate and schedule together",
|
||||
"team_onboarding_details_subtitle": "Add your team's name and create a unique URL for your team",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ services:
|
||||
calcom-api:
|
||||
container_name: calcom-api
|
||||
build:
|
||||
context: ./calcom
|
||||
context: .
|
||||
dockerfile: apps/api/v2/Dockerfile
|
||||
args:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
|
||||
@@ -16355,7 +16355,7 @@
|
||||
"description": "Boolean to Hide organizer's email address from the booking screen, email notifications, and calendar events"
|
||||
},
|
||||
"calVideoSettings": {
|
||||
"description": "Cal video settings for the event type",
|
||||
"description": "Cal video settings for the event type. Platform customers can't manage this property because currently we have no way of determining if managed user is a host or an attendee.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/CalVideoSettings"
|
||||
@@ -19492,7 +19492,7 @@
|
||||
"description": "Boolean to Hide organizer's email address from the booking screen, email notifications, and calendar events"
|
||||
},
|
||||
"calVideoSettings": {
|
||||
"description": "Cal video settings for the event type",
|
||||
"description": "Cal video settings for the event type. Platform customers can't manage this property because currently we have no way of determining if managed user is a host or an attendee.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/CalVideoSettings"
|
||||
|
||||
@@ -30,7 +30,7 @@ title: "Stripe"
|
||||
as the webhook for connected applications.
|
||||
</Step>
|
||||
<Step title="Select webhook events">
|
||||
Select all `payment_intent` events for the webhook.
|
||||
Select all `payment_intent` and `setup_intent` events for the webhook.
|
||||
</Step>
|
||||
<Step title="Save the webhook secret">
|
||||
Copy the webhook secret (`whsec_...`) to `STRIPE_WEBHOOK_SECRET` in the `.env` file.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"@getalby/lightning-tools": "^5.1.2",
|
||||
"@getalby/sdk": "^4.0.0",
|
||||
"@webbtc/webln-types": "^3.0.0",
|
||||
"react-qr-code": "^2.0.12",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"svix": "^0.85.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -111,15 +111,15 @@ const setEmbedNonStyles = (stylesConfig: EmbedNonStylesConfig) => {
|
||||
const registerNewSetter = (
|
||||
registration:
|
||||
| {
|
||||
elementName: keyof EmbedStyles;
|
||||
setState: SetStyles;
|
||||
styles: true;
|
||||
}
|
||||
elementName: keyof EmbedStyles;
|
||||
setState: SetStyles;
|
||||
styles: true;
|
||||
}
|
||||
| {
|
||||
elementName: keyof EmbedNonStylesConfig;
|
||||
setState: setNonStylesConfig;
|
||||
styles: false;
|
||||
}
|
||||
elementName: keyof EmbedNonStylesConfig;
|
||||
setState: setNonStylesConfig;
|
||||
styles: false;
|
||||
}
|
||||
) => {
|
||||
// It's possible that 'ui' instruction has already been processed and the registration happened due to some action by the user in iframe.
|
||||
// So, we should call the setter immediately with available embedStyles
|
||||
@@ -286,6 +286,9 @@ function makeBodyVisible() {
|
||||
if (document.body.style.visibility !== "visible") {
|
||||
document.body.style.visibility = "visible";
|
||||
}
|
||||
if (document.body.style.opacity !== "1") {
|
||||
document.body.style.opacity = "1";
|
||||
}
|
||||
// Ensure that it stays visible and not reverted by React
|
||||
runAsap(() => {
|
||||
makeBodyVisible();
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
|
||||
const shouldShowOnboarding = (
|
||||
@@ -31,6 +31,7 @@ export const ONBOARDING_NEXT_REDIRECT = {
|
||||
|
||||
export function useRedirectToOnboardingIfNeeded() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { data: user, isLoading } = useMeQuery();
|
||||
const flags = useFlagMap();
|
||||
|
||||
@@ -38,14 +39,17 @@ export function useRedirectToOnboardingIfNeeded() {
|
||||
!user?.emailVerified && user?.identityProvider === "CAL" && flags["email-verification"];
|
||||
|
||||
const shouldRedirectToOnboarding = user && shouldShowOnboarding(user);
|
||||
const canRedirect = !isLoading && shouldRedirectToOnboarding && !needsEmailVerification;
|
||||
// Don't redirect if already on an onboarding page (works for both old [[...step]] and v3 flows)
|
||||
const isOnOnboardingPage = pathname?.startsWith("/onboarding/") || pathname?.startsWith("/getting-started");
|
||||
const canRedirect =
|
||||
!isLoading && shouldRedirectToOnboarding && !needsEmailVerification && !isOnOnboardingPage;
|
||||
|
||||
useEffect(() => {
|
||||
if (canRedirect) {
|
||||
const gettingStartedPath = flags["onboarding-v3"] ? "/onboarding/getting-started" : "/getting-started";
|
||||
router.replace(gettingStartedPath);
|
||||
}
|
||||
}, [canRedirect, router, flags]);
|
||||
}, [canRedirect, router, flags, pathname]);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
|
||||
@@ -68,6 +68,7 @@ export const VerifyCodeDialog = ({
|
||||
value,
|
||||
email,
|
||||
verifyCodeWithSessionNotRequired,
|
||||
setVerificationCode,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -77,7 +78,8 @@ export const VerifyCodeDialog = ({
|
||||
if (hasVerified || error || isPending || !/^\d{6}$/.test(value.trim())) return;
|
||||
|
||||
verifyCode();
|
||||
}, [error, isPending, value, verifyCode, hasVerified]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [error, isPending, value, hasVerified]);
|
||||
|
||||
useEffect(() => setValue(""), [isOpenDialog]);
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ export class BotDetectionService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(eventTypeId) || eventTypeId <= 0) {
|
||||
throw new Error(`Invalid eventTypeId: ${eventTypeId}. Must be a positive integer.`);
|
||||
}
|
||||
|
||||
// Fetch only the teamId from the event type
|
||||
const eventType = await this.eventTypeRepository.getTeamIdByEventTypeId({
|
||||
id: eventTypeId,
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
|
||||
import { Icon, type IconName } from "@calcom/ui/components/icon";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
import { useWelcomeModal } from "../hooks/useWelcomeModal";
|
||||
|
||||
const features = [
|
||||
"1_parent_team_unlimited_subteams",
|
||||
"organization_workflows",
|
||||
"custom_subdomain",
|
||||
"instant_meetings",
|
||||
"collective_round_robin_events",
|
||||
"routing_forms",
|
||||
"team_workflows",
|
||||
];
|
||||
|
||||
export function WelcomeToOrganizationsModal() {
|
||||
const { t } = useLocale();
|
||||
const { isOpen, closeModal } = useWelcomeModal();
|
||||
|
||||
const SMALL = { outer: 32, icon: 16 };
|
||||
const LARGE = { outer: 48, icon: 24 };
|
||||
const RINGS = [60, 95, 130]; // Ring radii in px
|
||||
const RING_STROKE = 1;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}>
|
||||
<DialogContent size="default" className="!p-0">
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Logo className="h-10 w-auto" />
|
||||
</div>
|
||||
|
||||
{/* Team illustration with rings */}
|
||||
<div
|
||||
className="relative mx-auto"
|
||||
style={{
|
||||
width: 320,
|
||||
height: 220,
|
||||
maskImage: "radial-gradient(ellipse 100% 60% at center, black 30%, transparent 85%)",
|
||||
WebkitMaskImage: "radial-gradient(ellipse 100% 60% at center, black 30%, transparent 85%)",
|
||||
}}>
|
||||
{/* Center origin */}
|
||||
<div className="absolute left-1/2 top-1/2" style={{ transform: "translate(-50%, -50%)" }}>
|
||||
{/* Rings */}
|
||||
{RINGS.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="pointer-events-none absolute rounded-full border"
|
||||
style={{
|
||||
width: 2 * r,
|
||||
height: 2 * r,
|
||||
left: `calc(50% - ${r}px)`,
|
||||
top: `calc(50% - ${r}px)`,
|
||||
borderWidth: RING_STROKE,
|
||||
borderColor: "var(--cal-border-subtle)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Central users icon */}
|
||||
<div
|
||||
className="from-default to-muted border-subtle absolute flex items-center justify-center rounded-full border bg-gradient-to-b shadow-sm"
|
||||
style={{
|
||||
width: LARGE.outer,
|
||||
height: LARGE.outer,
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
}}>
|
||||
<Icon
|
||||
name="users"
|
||||
className="text-emphasis opacity-70"
|
||||
style={{ width: LARGE.icon, height: LARGE.icon }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Surrounding user icons */}
|
||||
{(
|
||||
[
|
||||
{ initialDeg: 30, duration: 20, icon: "user" },
|
||||
{ initialDeg: 190, duration: 25, icon: "user" },
|
||||
{ initialDeg: 320, duration: 15, icon: "user" },
|
||||
{ initialDeg: 280, duration: 20, icon: "building" },
|
||||
] as Array<{ initialDeg: number; duration: number; icon: IconName }>
|
||||
).map(({ initialDeg, duration, icon }, index) => {
|
||||
const r = RINGS[index % RINGS.length]; // icon orbit radius - each icon on a different ring (we have more icons than ring so we cycle through them)
|
||||
const steps = 60;
|
||||
const xKeyframes = [];
|
||||
const yKeyframes = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const angle = initialDeg + (360 * i) / steps;
|
||||
xKeyframes.push(r * Math.cos((angle * Math.PI) / 180));
|
||||
yKeyframes.push(r * Math.sin((angle * Math.PI) / 180));
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="from-default to-muted border-subtle absolute flex items-center justify-center rounded-full border bg-gradient-to-b shadow-sm"
|
||||
style={{
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
width: SMALL.outer,
|
||||
height: SMALL.outer,
|
||||
}}
|
||||
animate={{
|
||||
x: xKeyframes,
|
||||
y: yKeyframes,
|
||||
}}
|
||||
transition={{
|
||||
duration,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
}}
|
||||
transformTemplate={({ x, y }) =>
|
||||
// Lock the icon to the center of the ring and only translate on x and y
|
||||
`translate(-50%, -50%) translateX(${x}) translateY(${y})`
|
||||
}>
|
||||
<Icon
|
||||
name={icon}
|
||||
className="text-default"
|
||||
style={{ width: SMALL.icon, height: SMALL.icon }}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-col gap-2 text-center">
|
||||
<h2 className="font-cal text-emphasis text-2xl leading-none">{t("welcome_to_organizations")}</h2>
|
||||
<p className="text-default text-sm leading-normal">{t("organizations_welcome_description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-col gap-3">
|
||||
{features.map((feature) => (
|
||||
<div key={feature} className="flex items-start gap-2">
|
||||
<Icon name="check" className="text-muted mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-default text-sm font-medium leading-tight">{t(feature)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted border-subtle mt-6 flex items-center justify-between rounded-b-2xl border-t px-8 py-6">
|
||||
<Button
|
||||
color="minimal"
|
||||
href="https://cal.com/docs/organizations"
|
||||
target="_blank"
|
||||
EndIcon="external-link"
|
||||
className="pointer-events-none opacity-0">
|
||||
{t("learn_more")}
|
||||
</Button>
|
||||
<Button color="primary" onClick={closeModal}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { parseAsBoolean, useQueryState } from "nuqs";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const STORAGE_KEY = "showNewOrgModal";
|
||||
|
||||
export function useWelcomeModal() {
|
||||
const [newOrganizationModal, setNewOrganizationModal] = useQueryState(
|
||||
"newOrganizationModal",
|
||||
parseAsBoolean.withDefault(false)
|
||||
);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check query param first
|
||||
if (newOrganizationModal) {
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check sessionStorage as fallback (for cases where we redirect through personal onboarding)
|
||||
if (typeof window !== "undefined" && sessionStorage.getItem(STORAGE_KEY) === "true") {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [newOrganizationModal]);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
// Remove the query param from URL
|
||||
setNewOrganizationModal(null);
|
||||
// Also clear sessionStorage
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
closeModal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to set the flag that triggers the welcome modal.
|
||||
* Use this before redirecting to ensure the modal shows after navigation.
|
||||
*/
|
||||
export function setShowNewOrgModalFlag() {
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.setItem(STORAGE_KEY, "true");
|
||||
}
|
||||
}
|
||||
@@ -26,13 +26,23 @@ const RoundRobinSettings = ({ team }: RoundRobinSettingsProps) => {
|
||||
const rrResetInterval = team?.rrResetInterval ?? undefined;
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const initialResetInterval = rrResetInterval ?? RRResetInterval.MONTH;
|
||||
const initialTimestampBasis = team?.rrTimestampBasis ?? RRTimestampBasis.CREATED_AT;
|
||||
|
||||
const form = useForm<{ rrResetInterval: RRResetInterval; rrTimestampBasis: RRTimestampBasis }>({
|
||||
defaultValues: {
|
||||
rrResetInterval: rrResetInterval ?? RRResetInterval.MONTH,
|
||||
rrTimestampBasis: team?.rrTimestampBasis ?? RRTimestampBasis.CREATED_AT,
|
||||
rrResetInterval: initialResetInterval,
|
||||
rrTimestampBasis: initialTimestampBasis,
|
||||
},
|
||||
});
|
||||
|
||||
const [watchedResetInterval, watchedTimestampBasis] = form.watch([
|
||||
"rrResetInterval",
|
||||
"rrTimestampBasis",
|
||||
]);
|
||||
const hasChanges =
|
||||
watchedResetInterval !== initialResetInterval || watchedTimestampBasis !== initialTimestampBasis;
|
||||
|
||||
const mutation = trpc.viewer.teams.update.useMutation({
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
@@ -85,7 +95,7 @@ const RoundRobinSettings = ({ team }: RoundRobinSettingsProps) => {
|
||||
</div>
|
||||
</div>
|
||||
<SectionBottomActions align="end">
|
||||
<Button type="submit" color="primary" loading={mutation.isPending}>
|
||||
<Button type="submit" disabled={!hasChanges} color="primary" loading={mutation.isPending}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
|
||||
@@ -49,10 +49,12 @@ export const generateTeamCheckoutSession = async ({
|
||||
teamName,
|
||||
teamSlug,
|
||||
userId,
|
||||
isOnboarding,
|
||||
}: {
|
||||
teamName: string;
|
||||
teamSlug: string;
|
||||
userId: number;
|
||||
isOnboarding?: boolean;
|
||||
}) => {
|
||||
const [customer, dubCustomer] = await Promise.all([
|
||||
getStripeCustomerIdFromUserId(userId),
|
||||
@@ -98,6 +100,7 @@ export const generateTeamCheckoutSession = async ({
|
||||
teamSlug,
|
||||
userId,
|
||||
dubCustomerId: userId, // pass the userId during checkout creation for sales conversion tracking: https://d.to/conversions/stripe
|
||||
...(isOnboarding !== undefined && { isOnboarding: isOnboarding.toString() }),
|
||||
},
|
||||
});
|
||||
return session;
|
||||
|
||||
@@ -40,6 +40,7 @@ interface IAddActionDialog {
|
||||
needsCredits: boolean;
|
||||
creditsTeamId?: number;
|
||||
isOrganization?: boolean;
|
||||
needsTeamsUpgrade?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -170,7 +171,16 @@ export const AddActionDialog = (props: IAddActionDialog) => {
|
||||
menuPlacement="bottom"
|
||||
defaultValue={actionOptions[0]}
|
||||
onChange={handleSelectAction}
|
||||
options={actionOptions}
|
||||
options={actionOptions.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
needsTeamsUpgrade: option.needsTeamsUpgrade,
|
||||
}))}
|
||||
isOptionDisabled={(option: {
|
||||
label: string;
|
||||
value: WorkflowActions;
|
||||
needsTeamsUpgrade?: boolean;
|
||||
}) => !!option.needsTeamsUpgrade}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Dispatch, SetStateAction } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import { useHasActiveTeamPlan } from "@calcom/features/billing/hooks/useHasPaidPlan";
|
||||
import type { WorkflowPermissions } from "@calcom/features/workflows/repositories/WorkflowPermissionsRepository";
|
||||
import { SENDER_ID, SENDER_NAME, SCANNING_WORKFLOW_STEPS } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -43,6 +44,7 @@ export default function WorkflowDetailsPage(props: Props) {
|
||||
const { form, workflowId, selectedOptions, setSelectedOptions, teamId, isOrg, allOptions, permissions } =
|
||||
props;
|
||||
const { t, i18n } = useLocale();
|
||||
const { hasActiveTeamPlan } = useHasActiveTeamPlan();
|
||||
|
||||
const [isAddActionDialogOpen, setIsAddActionDialogOpen] = useState(false);
|
||||
const [isDeleteStepDialogOpen, setIsDeleteStepDialogOpen] = useState(false);
|
||||
@@ -84,12 +86,15 @@ export default function WorkflowDetailsPage(props: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const needsTeamsUpgrade = isFormTrigger(form.getValues("trigger")) && !hasActiveTeamPlan;
|
||||
|
||||
return {
|
||||
...option,
|
||||
label,
|
||||
creditsTeamId: teamId,
|
||||
isOrganization: isOrg,
|
||||
isCalAi: isCalAIAction(option.value),
|
||||
needsTeamsUpgrade,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
@@ -101,6 +101,7 @@ type WorkflowStepProps = {
|
||||
creditsTeamId?: number;
|
||||
isOrganization: boolean;
|
||||
isCalAi: boolean;
|
||||
needsTeamsUpgrade?: boolean;
|
||||
}[];
|
||||
updateTemplate: boolean;
|
||||
setUpdateTemplate: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -371,7 +372,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
handleCreateAgent,
|
||||
]);
|
||||
|
||||
const triggerOptions = getWorkflowTriggerOptions(t);
|
||||
const triggerOptions = getWorkflowTriggerOptions(t, hasActiveTeamPlan);
|
||||
const templateOptions = getWorkflowTemplateOptions(t, step?.action, hasActiveTeamPlan, trigger);
|
||||
|
||||
const steps = useWatch({
|
||||
@@ -379,6 +380,11 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
name: "steps",
|
||||
});
|
||||
|
||||
// Extract current step template from watched steps array
|
||||
const currentStepTemplate = step ? steps?.[step.stepNumber - 1]?.template : null;
|
||||
|
||||
const isReminderTemplate = currentStepTemplate === WorkflowTemplates.REMINDER;
|
||||
|
||||
const hasAiAction = hasCalAIAction(steps);
|
||||
const hasEmailToHostAction = steps.some((s) => s.action === WorkflowActions.EMAIL_HOST);
|
||||
const hasWhatsappAction = steps.some((s) => isWhatsappAction(s.action));
|
||||
@@ -531,11 +537,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
isDisabled={props.readOnly}
|
||||
onChange={(val) => {
|
||||
if (val) {
|
||||
const currentTrigger = form.getValues("trigger");
|
||||
const triggerValue = val.value as WorkflowTriggerEvents;
|
||||
const currentTrigger = form.getValues("trigger") as WorkflowTriggerEvents;
|
||||
const isCurrentFormTrigger = isFormTrigger(currentTrigger);
|
||||
const isNewFormTrigger = isFormTrigger(val.value);
|
||||
const isNewFormTrigger = isFormTrigger(triggerValue);
|
||||
|
||||
form.setValue("trigger", val.value);
|
||||
form.setValue("trigger", triggerValue);
|
||||
|
||||
// Reset activeOn when switching between form and non-form triggers
|
||||
if (isCurrentFormTrigger !== isNewFormTrigger) {
|
||||
@@ -546,12 +553,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
form.setValue("selectAll", false);
|
||||
}
|
||||
|
||||
const newTimeSectionText = getTimeSectionText(val.value, t);
|
||||
const newTimeSectionText = getTimeSectionText(triggerValue, t);
|
||||
if (newTimeSectionText) {
|
||||
setTimeSectionText(newTimeSectionText);
|
||||
if (
|
||||
val.value === WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW ||
|
||||
val.value === WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW
|
||||
triggerValue === WorkflowTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW ||
|
||||
triggerValue === WorkflowTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW
|
||||
) {
|
||||
form.setValue("time", 5);
|
||||
form.setValue("timeUnit", TimeUnit.MINUTE);
|
||||
@@ -564,7 +571,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
form.unregister("time");
|
||||
form.unregister("timeUnit");
|
||||
}
|
||||
if (isFormTrigger(val.value)) {
|
||||
if (isFormTrigger(triggerValue)) {
|
||||
const steps = form.getValues("steps");
|
||||
if (steps?.length) {
|
||||
const updatedSteps = steps.map((step) =>
|
||||
@@ -584,7 +591,14 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
}
|
||||
}}
|
||||
defaultValue={selectedTrigger}
|
||||
options={filteredTriggerOptions}
|
||||
options={filteredTriggerOptions.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
needsTeamsUpgrade: option.needsTeamsUpgrade,
|
||||
}))}
|
||||
isOptionDisabled={(option: { label: string; value: string; needsTeamsUpgrade?: boolean }) =>
|
||||
!!option.needsTeamsUpgrade
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
@@ -1321,7 +1335,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
refEmailSubject.current = e;
|
||||
}}
|
||||
rows={2}
|
||||
disabled={props.readOnly || !hasActiveTeamPlan}
|
||||
disabled={props.readOnly || (!hasActiveTeamPlan && !isReminderTemplate)}
|
||||
className="my-0 focus:ring-transparent"
|
||||
required
|
||||
{...restEmailSubjectForm}
|
||||
@@ -1354,7 +1368,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
editable={
|
||||
!props.readOnly &&
|
||||
!isWhatsappAction(step.action) &&
|
||||
(hasActiveTeamPlan || isSMSAction(step.action))
|
||||
(hasActiveTeamPlan || isSMSAction(step.action) || isReminderTemplate)
|
||||
}
|
||||
excludedToolbarItems={
|
||||
!isSMSAction(step.action) ? [] : ["blockType", "bold", "italic", "link"]
|
||||
|
||||
@@ -31,7 +31,7 @@ export function getWorkflowActionOptions(t: TFunction, isOrgsPlan?: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
export function getWorkflowTriggerOptions(t: TFunction) {
|
||||
export function getWorkflowTriggerOptions(t: TFunction, hasPaidPlan: boolean = false) {
|
||||
// TODO: remove this after workflows are supported
|
||||
const filterdWorkflowTriggerEvents = WORKFLOW_TRIGGER_EVENTS.filter(
|
||||
(event) =>
|
||||
@@ -41,8 +41,15 @@ export function getWorkflowTriggerOptions(t: TFunction) {
|
||||
|
||||
return filterdWorkflowTriggerEvents.map((triggerEvent) => {
|
||||
const triggerString = t(`${triggerEvent.toLowerCase()}_trigger`);
|
||||
const isFormSubmittedTrigger =
|
||||
triggerEvent === WorkflowTriggerEvents.FORM_SUBMITTED ||
|
||||
triggerEvent === WorkflowTriggerEvents.FORM_SUBMITTED_NO_EVENT;
|
||||
|
||||
return { label: triggerString.charAt(0).toUpperCase() + triggerString.slice(1), value: triggerEvent };
|
||||
return {
|
||||
label: triggerString.charAt(0).toUpperCase() + triggerString.slice(1),
|
||||
value: triggerEvent,
|
||||
needsTeamsUpgrade: !hasPaidPlan && isFormSubmittedTrigger,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,7 +62,7 @@ function convertToTemplateOptions(
|
||||
return {
|
||||
label: t(`${template.toLowerCase()}`),
|
||||
value: template,
|
||||
needsTeamsUpgrade: !hasPaidPlan,
|
||||
needsTeamsUpgrade: !hasPaidPlan && template !== WorkflowTemplates.REMINDER,
|
||||
} as { label: string; value: any; needsTeamsUpgrade: boolean };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function CreateEventTypeForm({
|
||||
required
|
||||
addOnLeading={
|
||||
!isPlatform ? (
|
||||
<span className="max-w-24 md:max-w-56">
|
||||
<span className="max-w-24 md:max-w-56 inline-block overflow-hidden text-ellipsis whitespace-nowrap min-w-0">
|
||||
{`/${!isManagedEventType ? pageSlug : t("username_placeholder")}/`}
|
||||
</span>
|
||||
) : undefined
|
||||
@@ -90,7 +90,7 @@ export default function CreateEventTypeForm({
|
||||
!isPlatform ? (
|
||||
<Tooltip
|
||||
content={`${urlPrefix}/${!isManagedEventType ? pageSlug : t("username_placeholder")}/`}>
|
||||
<span className="max-w-24 md:max-w-56">
|
||||
<span className="max-w-24 md:max-w-56 inline-block overflow-hidden text-ellipsis whitespace-nowrap min-w-0">
|
||||
{`${urlPrefix}/${!isManagedEventType ? pageSlug : t("username_placeholder")}/`}
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
@@ -293,6 +293,20 @@ export const MultiplePrivateLinksController = ({
|
||||
}
|
||||
/>
|
||||
<div className="ml-2 flex items-center">
|
||||
{!isExpired && (
|
||||
<Tooltip content={t("preview")}>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
StartIcon="external-link"
|
||||
data-testid="private-link-preview"
|
||||
href={singleUseURL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isExpired && (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, Suspense, useMemo } from "react";
|
||||
import { useState, Suspense, useMemo, useEffect } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
@@ -434,9 +434,14 @@ export const EventAdvancedTab = ({
|
||||
!!formMethods.getValues("multiplePrivateLinks") &&
|
||||
formMethods.getValues("multiplePrivateLinks")?.length !== 0
|
||||
);
|
||||
const watchedInterfaceLanguage = formMethods.watch("interfaceLanguage");
|
||||
const [interfaceLanguageVisible, setInterfaceLanguageVisible] = useState(
|
||||
!!formMethods.getValues("interfaceLanguage")
|
||||
watchedInterfaceLanguage !== null && watchedInterfaceLanguage !== undefined
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setInterfaceLanguageVisible(watchedInterfaceLanguage !== null && watchedInterfaceLanguage !== undefined);
|
||||
}, [watchedInterfaceLanguage]);
|
||||
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!formMethods.getValues("successRedirectUrl"));
|
||||
|
||||
const bookingFields: Prisma.JsonObject = {};
|
||||
@@ -784,7 +789,7 @@ export const EventAdvancedTab = ({
|
||||
<Controller
|
||||
name="interfaceLanguage"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.interfaceLanguage ?? ""}
|
||||
defaultValue={eventType.interfaceLanguage ?? null}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
@@ -817,7 +822,7 @@ export const EventAdvancedTab = ({
|
||||
onChange={(option) => {
|
||||
onChange(option?.value);
|
||||
}}
|
||||
value={interfaceLanguageOptions.find((option) => option.value === value)}
|
||||
value={interfaceLanguageOptions.find((option) => option.value === value) || undefined}
|
||||
/>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
|
||||
@@ -169,7 +169,7 @@ export const EventSetupTab = (
|
||||
className={classNames("pl-0", customClassNames?.titleSection?.urlInput?.input)}
|
||||
addOnLeading={
|
||||
isPlatform ? undefined : (
|
||||
<>
|
||||
<span className="max-w-24 md:max-w-56 inline-block overflow-hidden text-ellipsis whitespace-nowrap min-w-0">
|
||||
{urlPrefix}/
|
||||
{!isManagedEventType
|
||||
? team
|
||||
@@ -177,7 +177,7 @@ export const EventSetupTab = (
|
||||
: formMethods.getValues("users")[0].username
|
||||
: t("username_placeholder")}
|
||||
/
|
||||
</>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{...formMethods.register("slug", {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
|
||||
import mapKeys from "lodash/mapKeys";
|
||||
|
||||
import startCase from "lodash/startCase";
|
||||
|
||||
import {
|
||||
@@ -265,6 +263,7 @@ class RoutingEventsInsights {
|
||||
const fields = routingFormFieldsSchema.parse(routingForms.map((f) => f.fields).flat());
|
||||
const ids = new Set<string>();
|
||||
const headers = (fields || [])
|
||||
.filter((f) => !f.deleted)
|
||||
.map((f) => {
|
||||
return {
|
||||
id: f.id,
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
export function CalAiBanner() {
|
||||
const { t } = useLocale();
|
||||
const pathname = usePathname();
|
||||
const [isVisible, setIsVisible] = useState(() => {
|
||||
if (typeof window === "undefined") return true;
|
||||
return !localStorage.getItem("calai-banner-dismissed");
|
||||
});
|
||||
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem("calai-banner-dismissed", "true");
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
const handleTryNow = () => {
|
||||
window.open("/workflow/new?action=calAi&templateWorkflowId=wf-11", "_blank");
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
if (!(pathname === "/workflows" || pathname === "/event-types" || pathname === "/bookings/upcoming"))
|
||||
return null;
|
||||
|
||||
return (
|
||||
<div className="bg-launch-dark relative mb-6 w-full overflow-hidden rounded-lg text-white">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute right-2 top-2 z-10 rounded-[10px] p-2"
|
||||
aria-label="Close banner">
|
||||
<Icon name="x" className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<div className="flex-1 p-6 pr-6 lg:p-8">
|
||||
<h2 className="font-cal font-subtle text-lg">{t("supercharge_your_workflows_with_cal_ai")}</h2>
|
||||
<p className="text-subtle mb-4 text-sm">
|
||||
{t("supercharge_your_workflows_with_cal_ai_description")}
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleTryNow}
|
||||
className="rounded-[10px] bg-gradient-to-b from-[#6349EA] to-[#875FE0] px-5 py-2 text-sm font-medium text-white hover:from-[#5A42D1] hover:to-[#7B6FD7]">
|
||||
{t("try_now")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<img src="/cal-ai-banner.svg" alt="Cal.ai Banner" className="mt-auto hidden h-40 md:block" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { WelcomeToOrganizationsModal } from "@calcom/features/ee/organizations/components/WelcomeToOrganizationsModal";
|
||||
|
||||
/**
|
||||
* Container for all query-param driven modals that should appear globally across the app.
|
||||
* This keeps the Shell component clean and provides a centralized place for dynamic modals.
|
||||
*
|
||||
* We can probably also use this for thinks like the T&C and Privacy Policy modals. That @marketing are discussing
|
||||
*
|
||||
* To add a new modal:
|
||||
* 1. Create the modal component with its own useQueryState hook
|
||||
* 2. Import and add it here
|
||||
*/
|
||||
export function DynamicModals() {
|
||||
return (
|
||||
<>
|
||||
<WelcomeToOrganizationsModal />
|
||||
{/* Add more query-param driven modals here */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Dispatch, ReactElement, ReactNode, SetStateAction } from "react";
|
||||
import React, { cloneElement } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
@@ -17,7 +17,7 @@ import { Button } from "@calcom/ui/components/button";
|
||||
import { ErrorBoundary } from "@calcom/ui/components/errorBoundary";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
import { CalAiBanner } from "./CalAiBanner";
|
||||
import { DynamicModals } from "./DynamicModals";
|
||||
import { SideBarContainer } from "./SideBar";
|
||||
import { TopNavContainer } from "./TopNav";
|
||||
import { BannerContainer } from "./banners/LayoutBanner";
|
||||
@@ -27,7 +27,6 @@ import { useAppTheme } from "./useAppTheme";
|
||||
|
||||
const Layout = (props: LayoutProps) => {
|
||||
const { banners, bannersHeight } = useBanners();
|
||||
const pathname = usePathname();
|
||||
|
||||
useFormbricks();
|
||||
|
||||
@@ -38,6 +37,7 @@ const Layout = (props: LayoutProps) => {
|
||||
</div>
|
||||
|
||||
<TimezoneChangeDialog />
|
||||
<DynamicModals />
|
||||
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{banners && !props.isPlatformUser && <BannerContainer banners={banners} />}
|
||||
@@ -206,7 +206,6 @@ function MainContainer({
|
||||
{/* show top navigation for md and smaller (tablet and phones) */}
|
||||
{TopNavContainerProp}
|
||||
<div className="max-w-full p-2 sm:py-4 lg:px-6">
|
||||
<CalAiBanner />
|
||||
<ErrorBoundary>
|
||||
{!props.withoutMain ? <ShellMain {...props}>{props.children}</ShellMain> : props.children}
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -46,7 +46,7 @@ const useBuildHref = () => {
|
||||
childItem.preserveQueryParams &&
|
||||
childItem.preserveQueryParams({ prevPathname: prevPathnameRef.current, nextPathname: childItem.href })
|
||||
) {
|
||||
const params = searchParams.toString();
|
||||
const params = searchParams?.toString();
|
||||
return params ? `${childItem.href}?${params}` : childItem.href;
|
||||
}
|
||||
return childItem.href;
|
||||
|
||||
@@ -34,7 +34,7 @@ export const getEveryFreqFor = ({
|
||||
recurringCount?: number;
|
||||
recurringFreq?: string;
|
||||
}): string => {
|
||||
if (recurringEvent.freq) {
|
||||
if (recurringEvent.freq != null) {
|
||||
return `${recurringFreq || getRecurringFreq({ t, recurringEvent })} ${
|
||||
recurringCount || recurringEvent.count
|
||||
} ${t("occurrence", {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BookingReportReason } from "@calcom/prisma/enums";
|
||||
import type { BookingReportReason, BookingReportStatus } from "@calcom/prisma/enums";
|
||||
|
||||
export interface CreateBookingReportInput {
|
||||
bookingUid: string;
|
||||
@@ -18,8 +18,69 @@ export interface BookingReportSummary {
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface ListBookingReportsFilters {
|
||||
reason?: BookingReportReason[];
|
||||
cancelled?: boolean;
|
||||
hasWatchlist?: boolean;
|
||||
status?: BookingReportStatus[];
|
||||
}
|
||||
|
||||
export interface BookingReportWithDetails {
|
||||
id: string;
|
||||
bookingUid: string;
|
||||
bookerEmail: string;
|
||||
reportedById: number | null;
|
||||
reason: BookingReportReason;
|
||||
description: string | null;
|
||||
cancelled: boolean;
|
||||
createdAt: Date;
|
||||
status: BookingReportStatus;
|
||||
watchlistId: string | null;
|
||||
reporter: {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
} | null;
|
||||
booking: {
|
||||
id: number;
|
||||
uid: string;
|
||||
title: string | null;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
};
|
||||
watchlist: {
|
||||
id: string;
|
||||
type: string;
|
||||
value: string;
|
||||
action: string;
|
||||
description: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface IBookingReportRepository {
|
||||
createReport(input: CreateBookingReportInput): Promise<{ id: string }>;
|
||||
|
||||
findAllReportedBookings(params: { skip?: number; take?: number }): Promise<BookingReportSummary[]>;
|
||||
findAllReportedBookings(params: {
|
||||
organizationId?: number;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
searchTerm?: string;
|
||||
filters?: ListBookingReportsFilters;
|
||||
}): Promise<{
|
||||
rows: BookingReportWithDetails[];
|
||||
meta: { totalRowCount: number };
|
||||
}>;
|
||||
|
||||
findReportsByIds(params: {
|
||||
reportIds: string[];
|
||||
organizationId?: number;
|
||||
}): Promise<BookingReportWithDetails[]>;
|
||||
|
||||
linkWatchlistToReport(params: { reportId: string; watchlistId: string }): Promise<void>;
|
||||
|
||||
updateReportStatus(params: {
|
||||
reportId: string;
|
||||
status: BookingReportStatus;
|
||||
organizationId?: number;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ describe("PrismaBookingReportRepository", () => {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
@@ -94,36 +96,112 @@ describe("PrismaBookingReportRepository", () => {
|
||||
const mockReports = [
|
||||
{
|
||||
id: "report-1",
|
||||
bookingUid: "booking-1",
|
||||
bookerEmail: "user1@example.com",
|
||||
reportedById: 1,
|
||||
reason: BookingReportReason.SPAM,
|
||||
description: "Spam",
|
||||
cancelled: false,
|
||||
createdAt: new Date("2025-01-01T10:00:00Z"),
|
||||
status: "PENDING",
|
||||
watchlistId: null,
|
||||
reportedBy: null,
|
||||
booking: {
|
||||
id: 1,
|
||||
uid: "booking-1",
|
||||
title: "Test Booking",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
},
|
||||
watchlist: null,
|
||||
},
|
||||
{
|
||||
id: "report-2",
|
||||
bookingUid: "booking-2",
|
||||
bookerEmail: "user2@example.com",
|
||||
reportedById: 2,
|
||||
reason: BookingReportReason.DONT_KNOW_PERSON,
|
||||
description: null,
|
||||
cancelled: false,
|
||||
createdAt: new Date("2025-01-01T11:00:00Z"),
|
||||
status: "PENDING",
|
||||
watchlistId: null,
|
||||
reportedBy: null,
|
||||
booking: {
|
||||
id: 2,
|
||||
uid: "booking-2",
|
||||
title: "Test Booking 2",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
},
|
||||
watchlist: null,
|
||||
},
|
||||
];
|
||||
|
||||
mockPrisma.bookingReport.findMany.mockResolvedValue(mockReports);
|
||||
mockPrisma.bookingReport.count.mockResolvedValue(2);
|
||||
|
||||
const result = await repository.findAllReportedBookings({ skip: 0, take: 10 });
|
||||
|
||||
expect(result).toEqual(mockReports);
|
||||
expect(mockPrisma.bookingReport.findMany).toHaveBeenCalledWith({
|
||||
skip: 0,
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
reportedById: true,
|
||||
reason: true,
|
||||
description: true,
|
||||
createdAt: true,
|
||||
expect(result).toEqual({
|
||||
rows: mockReports.map((r) => ({ ...r, reporter: r.reportedBy })),
|
||||
meta: { totalRowCount: 2 },
|
||||
});
|
||||
expect(mockPrisma.bookingReport.findMany).toHaveBeenCalled();
|
||||
expect(mockPrisma.bookingReport.count).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateReportStatus", () => {
|
||||
it("should update report status with organizationId", async () => {
|
||||
mockPrisma.bookingReport.updateMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
await repository.updateReportStatus({
|
||||
reportId: "report-123",
|
||||
status: "BLOCKED",
|
||||
organizationId: 100,
|
||||
});
|
||||
|
||||
expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "report-123",
|
||||
organizationId: 100,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
data: { status: "BLOCKED" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should update report status without organizationId", async () => {
|
||||
mockPrisma.bookingReport.updateMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
await repository.updateReportStatus({
|
||||
reportId: "report-456",
|
||||
status: "DISMISSED",
|
||||
});
|
||||
|
||||
expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "report-456",
|
||||
},
|
||||
data: { status: "DISMISSED" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should update report status to PENDING", async () => {
|
||||
mockPrisma.bookingReport.updateMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
await repository.updateReportStatus({
|
||||
reportId: "report-789",
|
||||
status: "PENDING",
|
||||
organizationId: 200,
|
||||
});
|
||||
|
||||
expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "report-789",
|
||||
organizationId: 200,
|
||||
},
|
||||
data: { status: "PENDING" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/generated/prisma/client";
|
||||
|
||||
import type {
|
||||
IBookingReportRepository,
|
||||
CreateBookingReportInput,
|
||||
BookingReportSummary,
|
||||
BookingReportWithDetails,
|
||||
ListBookingReportsFilters,
|
||||
} from "./bookingReport.interface";
|
||||
|
||||
export class PrismaBookingReportRepository implements IBookingReportRepository {
|
||||
@@ -25,19 +27,189 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
|
||||
return report;
|
||||
}
|
||||
|
||||
async findAllReportedBookings(params: { skip?: number; take?: number }): Promise<BookingReportSummary[]> {
|
||||
async findAllReportedBookings(params: {
|
||||
organizationId?: number;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
searchTerm?: string;
|
||||
filters?: ListBookingReportsFilters;
|
||||
}): Promise<{
|
||||
rows: BookingReportWithDetails[];
|
||||
meta: { totalRowCount: number };
|
||||
}> {
|
||||
const where: Prisma.BookingReportWhereInput = {};
|
||||
|
||||
if (params.organizationId) {
|
||||
where.organizationId = params.organizationId;
|
||||
}
|
||||
|
||||
if (params.searchTerm) {
|
||||
where.OR = [
|
||||
{ bookerEmail: { contains: params.searchTerm, mode: "insensitive" } },
|
||||
{
|
||||
reportedBy: {
|
||||
email: { contains: params.searchTerm, mode: "insensitive" },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (params.filters?.hasWatchlist !== undefined) {
|
||||
where.watchlistId = params.filters.hasWatchlist ? { not: null } : null;
|
||||
}
|
||||
|
||||
if (params.filters?.reason && params.filters.reason.length > 0) {
|
||||
where.reason = { in: params.filters.reason };
|
||||
}
|
||||
|
||||
if (params.filters?.cancelled !== undefined) {
|
||||
where.cancelled = params.filters.cancelled;
|
||||
}
|
||||
|
||||
if (params.filters?.status && params.filters.status.length > 0) {
|
||||
where.status = { in: params.filters.status };
|
||||
}
|
||||
|
||||
const [reports, totalCount] = await Promise.all([
|
||||
this.prismaClient.bookingReport.findMany({
|
||||
where,
|
||||
skip: params.skip,
|
||||
take: params.take,
|
||||
select: {
|
||||
id: true,
|
||||
bookingUid: true,
|
||||
bookerEmail: true,
|
||||
reportedById: true,
|
||||
reason: true,
|
||||
description: true,
|
||||
cancelled: true,
|
||||
createdAt: true,
|
||||
status: true,
|
||||
watchlistId: true,
|
||||
reportedBy: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
booking: {
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
title: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
},
|
||||
},
|
||||
watchlist: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
value: true,
|
||||
action: true,
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
this.prismaClient.bookingReport.count({ where }),
|
||||
]);
|
||||
|
||||
const rows = reports.map((report) => ({
|
||||
...report,
|
||||
reporter: report.reportedBy,
|
||||
}));
|
||||
|
||||
return {
|
||||
rows,
|
||||
meta: { totalRowCount: totalCount },
|
||||
};
|
||||
}
|
||||
|
||||
async findReportsByIds(params: {
|
||||
reportIds: string[];
|
||||
organizationId?: number;
|
||||
}): Promise<BookingReportWithDetails[]> {
|
||||
const where: Prisma.BookingReportWhereInput = {
|
||||
id: { in: params.reportIds },
|
||||
};
|
||||
|
||||
if (params.organizationId) {
|
||||
where.organizationId = params.organizationId;
|
||||
}
|
||||
|
||||
const reports = await this.prismaClient.bookingReport.findMany({
|
||||
skip: params.skip,
|
||||
take: params.take,
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
bookingUid: true,
|
||||
bookerEmail: true,
|
||||
reportedById: true,
|
||||
reason: true,
|
||||
description: true,
|
||||
cancelled: true,
|
||||
createdAt: true,
|
||||
status: true,
|
||||
watchlistId: true,
|
||||
reportedBy: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
booking: {
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
title: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
},
|
||||
},
|
||||
watchlist: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
value: true,
|
||||
action: true,
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return reports;
|
||||
|
||||
return reports.map((report) => ({
|
||||
...report,
|
||||
reporter: report.reportedBy,
|
||||
}));
|
||||
}
|
||||
|
||||
async linkWatchlistToReport(params: { reportId: string; watchlistId: string }): Promise<void> {
|
||||
await this.prismaClient.bookingReport.update({
|
||||
where: { id: params.reportId },
|
||||
data: { watchlistId: params.watchlistId },
|
||||
});
|
||||
}
|
||||
|
||||
async updateReportStatus(params: {
|
||||
reportId: string;
|
||||
status: "PENDING" | "DISMISSED" | "BLOCKED";
|
||||
organizationId?: number;
|
||||
}): Promise<void> {
|
||||
const where: Prisma.BookingReportWhereInput = {
|
||||
id: params.reportId,
|
||||
};
|
||||
|
||||
if (params.organizationId) {
|
||||
where.organizationId = params.organizationId;
|
||||
}
|
||||
|
||||
await this.prismaClient.bookingReport.updateMany({
|
||||
where,
|
||||
data: { status: params.status },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,16 +28,18 @@ export interface WatchlistAuditEntry {
|
||||
export interface CreateWatchlistInput {
|
||||
type: WatchlistType;
|
||||
value: string;
|
||||
organizationId: number;
|
||||
organizationId: number | null;
|
||||
action: WatchlistAction;
|
||||
description?: string;
|
||||
userId: number;
|
||||
isGlobal?: boolean;
|
||||
}
|
||||
|
||||
export interface CheckWatchlistInput {
|
||||
type: WatchlistType;
|
||||
value: string;
|
||||
organizationId: number;
|
||||
organizationId?: number | null;
|
||||
isGlobal?: boolean;
|
||||
}
|
||||
|
||||
export interface FindAllEntriesInput {
|
||||
@@ -59,8 +61,17 @@ export interface IWatchlistRepository {
|
||||
})[];
|
||||
meta: { totalRowCount: number };
|
||||
}>;
|
||||
findEntryWithAudit(id: string): Promise<{
|
||||
entry: WatchlistEntry | null;
|
||||
findEntryWithAuditAndReports(id: string): Promise<{
|
||||
entry:
|
||||
| (WatchlistEntry & {
|
||||
bookingReports?: Array<{
|
||||
booking: {
|
||||
uid: string;
|
||||
title: string | null;
|
||||
};
|
||||
}>;
|
||||
})
|
||||
| null;
|
||||
auditHistory: WatchlistAuditEntry[];
|
||||
}>;
|
||||
deleteEntry(id: string, userId: number): Promise<void>;
|
||||
|
||||
@@ -18,6 +18,7 @@ export class WatchlistRepository implements IWatchlistRepository {
|
||||
type: params.type,
|
||||
value: params.value,
|
||||
organizationId: params.organizationId,
|
||||
isGlobal: params.isGlobal,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
@@ -33,7 +34,7 @@ export class WatchlistRepository implements IWatchlistRepository {
|
||||
action: params.action,
|
||||
description: params.description,
|
||||
source: WatchlistSource.MANUAL,
|
||||
isGlobal: false,
|
||||
isGlobal: params.isGlobal ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -55,17 +56,35 @@ export class WatchlistRepository implements IWatchlistRepository {
|
||||
}
|
||||
|
||||
async checkExists(params: CheckWatchlistInput): Promise<WatchlistEntry | null> {
|
||||
const entry = await this.prismaClient.watchlist.findUnique({
|
||||
where: {
|
||||
type_value_organizationId: {
|
||||
if (!params.isGlobal && !params.organizationId) {
|
||||
throw new Error("Both isGlobal and organizationId are missing");
|
||||
}
|
||||
|
||||
if (params.isGlobal) {
|
||||
const entry = await this.prismaClient.watchlist.findFirst({
|
||||
where: {
|
||||
type: params.type,
|
||||
value: params.value,
|
||||
organizationId: params.organizationId,
|
||||
isGlobal: true,
|
||||
organizationId: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
return entry;
|
||||
} else if (params.organizationId) {
|
||||
const entry = await this.prismaClient.watchlist.findUnique({
|
||||
where: {
|
||||
type_value_organizationId: {
|
||||
type: params.type,
|
||||
value: params.value,
|
||||
organizationId: params.organizationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async findAllEntries(params: FindAllEntriesInput): Promise<{
|
||||
@@ -123,8 +142,17 @@ export class WatchlistRepository implements IWatchlistRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async findEntryWithAudit(id: string): Promise<{
|
||||
entry: WatchlistEntry | null;
|
||||
async findEntryWithAuditAndReports(id: string): Promise<{
|
||||
entry:
|
||||
| (WatchlistEntry & {
|
||||
bookingReports?: Array<{
|
||||
booking: {
|
||||
uid: string;
|
||||
title: string | null;
|
||||
};
|
||||
}>;
|
||||
})
|
||||
| null;
|
||||
auditHistory: WatchlistAuditEntry[];
|
||||
}> {
|
||||
const entry = await this.prismaClient.watchlist.findUnique({
|
||||
@@ -139,6 +167,16 @@ export class WatchlistRepository implements IWatchlistRepository {
|
||||
isGlobal: true,
|
||||
source: true,
|
||||
lastUpdatedAt: true,
|
||||
bookingReports: {
|
||||
select: {
|
||||
booking: {
|
||||
select: {
|
||||
uid: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
audits: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -169,6 +207,7 @@ export class WatchlistRepository implements IWatchlistRepository {
|
||||
isGlobal: entry.isGlobal,
|
||||
source: entry.source,
|
||||
lastUpdatedAt: entry.lastUpdatedAt,
|
||||
bookingReports: entry.bookingReports,
|
||||
}
|
||||
: null,
|
||||
auditHistory: entry?.audits || [],
|
||||
|
||||
@@ -14,7 +14,7 @@ import { showToast } from "@calcom/ui/components/toast";
|
||||
import { getUtmTrackingParameters } from "../../lib/getUtmTrackingParameters";
|
||||
import type { UseCreateBookingInput } from "./useCreateBooking";
|
||||
|
||||
type Callbacks = { onSuccess?: () => void; onError?: (err: any) => void };
|
||||
type Callbacks = { onSuccess?: () => void; onError?: (err: unknown) => void };
|
||||
type UseHandleBookingProps = {
|
||||
bookingForm: UseBookingFormReturnType["bookingForm"];
|
||||
event?: {
|
||||
@@ -64,8 +64,8 @@ export const useHandleBookEvent = ({
|
||||
const crmAppSlug = useBookerStoreContext((state) => state.crmAppSlug);
|
||||
const crmRecordId = useBookerStoreContext((state) => state.crmRecordId);
|
||||
const verificationCode = useBookerStoreContext((state) => state.verificationCode);
|
||||
const handleError = (err: any) => {
|
||||
const errorMessage = err?.message ? t(err.message) : t("can_you_try_again");
|
||||
const handleError = (err: unknown) => {
|
||||
const errorMessage = err instanceof Error ? t(err.message) : t("can_you_try_again");
|
||||
showToast(errorMessage, "error");
|
||||
};
|
||||
const searchParams = useSearchParams();
|
||||
@@ -121,7 +121,7 @@ export const useHandleBookEvent = ({
|
||||
|
||||
if (isInstantMeeting) {
|
||||
handleInstantBooking(mapBookingToMutationInput(bookingInput), callbacks);
|
||||
} else if (event.data?.recurringEvent?.freq && recurringEventCount && !rescheduleUid) {
|
||||
} else if (event.data?.recurringEvent?.freq != null && recurringEventCount && !rescheduleUid) {
|
||||
handleRecBooking(
|
||||
mapRecurringBookingToMutationInput(bookingInput, recurringEventCount, tracking),
|
||||
callbacks
|
||||
|
||||
+2
-1
@@ -493,7 +493,8 @@ export class BaseCreateEventTypeInput {
|
||||
@ValidateNested()
|
||||
@Type(() => CalVideoSettings)
|
||||
@DocsPropertyOptional({
|
||||
description: "Cal video settings for the event type",
|
||||
description:
|
||||
"Cal video settings for the event type. Platform customers can't manage this property because currently we have no way of determining if managed user is a host or an attendee.",
|
||||
type: CalVideoSettings,
|
||||
})
|
||||
calVideoSettings?: CalVideoSettings;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "public"."BookingReportStatus" AS ENUM ('PENDING', 'DISMISSED', 'BLOCKED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."BookingReport" ADD COLUMN "status" "public"."BookingReportStatus" NOT NULL DEFAULT 'PENDING';
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Made the column `uuid` on table `users` required. This step will fail if there are existing NULL values in that column.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."users" ALTER COLUMN "uuid" SET NOT NULL;
|
||||
@@ -349,7 +349,7 @@ model TravelSchedule {
|
||||
// It holds Personal Profiles of a User plus it has email, password and other core things..
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
uuid String? @unique @default(uuid()) @db.Uuid
|
||||
uuid String @unique @default(uuid()) @db.Uuid
|
||||
username String?
|
||||
name String?
|
||||
/// @zod.import(["import { emailSchema } from '@calcom/lib/emailSchema'"]).custom.use(emailSchema)
|
||||
@@ -2366,6 +2366,12 @@ enum BookingReportReason {
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum BookingReportStatus {
|
||||
PENDING // Initial state - awaiting review
|
||||
DISMISSED // User clicked "Don't block"
|
||||
BLOCKED // Added to watchlist
|
||||
}
|
||||
|
||||
model BookingReport {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
bookingUid String @unique
|
||||
@@ -2378,6 +2384,7 @@ model BookingReport {
|
||||
cancelled Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
status BookingReportStatus @default(PENDING)
|
||||
|
||||
watchlistId String? @db.Uuid
|
||||
|
||||
|
||||
@@ -796,7 +796,7 @@ 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,})?$/;
|
||||
const regex = /^(?:@?[a-z0-9-]+(?:\.[a-z]{2,})?)?(?:@[a-z0-9-]+\.[a-z]{2,})?$/i;
|
||||
|
||||
/*
|
||||
Valid patterns - [ example, example.anything, anyone@example.anything ]
|
||||
|
||||
@@ -26,6 +26,9 @@ import { ZGetOtherTeamInputSchema } from "./getOtherTeam.handler";
|
||||
import { ZGetUserInput } from "./getUser.schema";
|
||||
import { ZGetWatchlistEntryDetailsInputSchema } from "./getWatchlistEntryDetails.schema";
|
||||
import { ZIntentToCreateOrgInputSchema } from "./intentToCreateOrg.schema";
|
||||
import { ZAddToWatchlistInputSchema } from "./addToWatchlist.schema";
|
||||
import { ZDismissBookingReportInputSchema } from "./dismissBookingReport.schema";
|
||||
import { ZListBookingReportsInputSchema } from "./listBookingReports.schema";
|
||||
import { ZListMembersInputSchema } from "./listMembers.schema";
|
||||
import { ZListOtherTeamMembersSchema } from "./listOtherTeamMembers.handler";
|
||||
import { ZListWatchlistEntriesInputSchema } from "./listWatchlistEntries.schema";
|
||||
@@ -191,5 +194,23 @@ export const viewerOrganizationsRouter = router({
|
||||
const { default: handler } = await import("./getWatchlistEntryDetails.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
listBookingReports: authedOrgAdminProcedure
|
||||
.input(ZListBookingReportsInputSchema)
|
||||
.query(async (opts) => {
|
||||
const { default: handler } = await import("./listBookingReports.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
addToWatchlist: authedOrgAdminProcedure
|
||||
.input(ZAddToWatchlistInputSchema)
|
||||
.mutation(async (opts) => {
|
||||
const { default: handler } = await import("./addToWatchlist.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
dismissBookingReport: authedOrgAdminProcedure
|
||||
.input(ZDismissBookingReportInputSchema)
|
||||
.mutation(async (opts) => {
|
||||
const { default: handler } = await import("./dismissBookingReport.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
});
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import type { WatchlistEntry } from "@calcom/lib/server/repository/watchlist.interface";
|
||||
import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
|
||||
import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
|
||||
import {
|
||||
MembershipRole,
|
||||
WatchlistType,
|
||||
WatchlistAction,
|
||||
WatchlistSource,
|
||||
BookingReportReason,
|
||||
} from "@calcom/prisma/enums";
|
||||
|
||||
import { addToWatchlistHandler } from "./addToWatchlist.handler";
|
||||
|
||||
vi.mock("@calcom/features/pbac/services/permission-check.service");
|
||||
vi.mock("@calcom/lib/server/repository/bookingReport");
|
||||
vi.mock("@calcom/lib/server/repository/watchlist.repository");
|
||||
|
||||
describe("addToWatchlistHandler (Organization)", () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
email: "admin@example.com",
|
||||
organizationId: 100,
|
||||
profile: null,
|
||||
};
|
||||
|
||||
const mockReports = [
|
||||
{
|
||||
id: "report-1",
|
||||
bookingUid: "booking-uid-1",
|
||||
bookerEmail: "Spammer@Example.com",
|
||||
reportedById: 1,
|
||||
reason: BookingReportReason.SPAM,
|
||||
description: null,
|
||||
cancelled: false,
|
||||
createdAt: new Date(),
|
||||
watchlistId: null,
|
||||
reporter: { id: 1, name: "Admin", email: "admin@example.com" },
|
||||
booking: {
|
||||
id: 1,
|
||||
uid: "booking-uid-1",
|
||||
title: "Test Booking",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
},
|
||||
watchlist: null,
|
||||
},
|
||||
{
|
||||
id: "report-2",
|
||||
bookingUid: "booking-uid-2",
|
||||
bookerEmail: " User@Spammer.com ",
|
||||
reportedById: 1,
|
||||
reason: BookingReportReason.SPAM,
|
||||
description: null,
|
||||
cancelled: false,
|
||||
createdAt: new Date(),
|
||||
watchlistId: null,
|
||||
reporter: { id: 1, name: "Admin", email: "admin@example.com" },
|
||||
booking: {
|
||||
id: 2,
|
||||
uid: "booking-uid-2",
|
||||
title: "Test Booking 2",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
},
|
||||
watchlist: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockPermissionCheckService = {
|
||||
checkPermission: vi.fn(),
|
||||
};
|
||||
|
||||
const mockReportRepo = {
|
||||
findReportsByIds: vi.fn(),
|
||||
linkWatchlistToReport: vi.fn(),
|
||||
updateReportStatus: vi.fn(),
|
||||
};
|
||||
|
||||
const mockWatchlistRepo = {
|
||||
checkExists: vi.fn(),
|
||||
createEntry: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(PermissionCheckService).mockImplementation(
|
||||
() => mockPermissionCheckService as InstanceType<typeof PermissionCheckService>
|
||||
);
|
||||
vi.mocked(PrismaBookingReportRepository).mockImplementation(
|
||||
() => mockReportRepo as InstanceType<typeof PrismaBookingReportRepository>
|
||||
);
|
||||
vi.mocked(WatchlistRepository).mockImplementation(
|
||||
() => mockWatchlistRepo as InstanceType<typeof WatchlistRepository>
|
||||
);
|
||||
});
|
||||
|
||||
describe("access control", () => {
|
||||
it("should throw FORBIDDEN when user is not part of an organization", async () => {
|
||||
await expect(
|
||||
addToWatchlistHandler({
|
||||
ctx: { user: { ...mockUser, organizationId: undefined, profile: null } },
|
||||
input: {
|
||||
reportIds: ["report-1"],
|
||||
type: WatchlistType.EMAIL,
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
message: "You must be part of an organization to add to watchlist",
|
||||
});
|
||||
|
||||
expect(mockPermissionCheckService.checkPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw UNAUTHORIZED when user lacks permission", async () => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
addToWatchlistHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
reportIds: ["report-1"],
|
||||
type: WatchlistType.EMAIL,
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to add entries to the watchlist",
|
||||
});
|
||||
|
||||
expect(mockReportRepo.findReportsByIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should check permission with correct parameters", async () => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]);
|
||||
mockWatchlistRepo.checkExists.mockResolvedValue(null);
|
||||
mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-new" });
|
||||
mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined);
|
||||
mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
|
||||
|
||||
await addToWatchlistHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
reportIds: ["report-1"],
|
||||
type: WatchlistType.EMAIL,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
|
||||
userId: 1,
|
||||
teamId: 100,
|
||||
permission: "watchlist.create",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation", () => {
|
||||
beforeEach(() => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should throw NOT_FOUND when report IDs don't exist", async () => {
|
||||
mockReportRepo.findReportsByIds.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
addToWatchlistHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
reportIds: ["report-1", "report-2"],
|
||||
type: WatchlistType.EMAIL,
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: expect.stringContaining("Booking report(s) not found"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NOT_FOUND with specific missing IDs", async () => {
|
||||
mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]);
|
||||
|
||||
await expect(
|
||||
addToWatchlistHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
reportIds: ["report-1", "report-2", "report-3"],
|
||||
type: WatchlistType.EMAIL,
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: expect.stringContaining("report-2, report-3"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicate prevention", () => {
|
||||
beforeEach(() => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should throw BAD_REQUEST when all reports are already in watchlist", async () => {
|
||||
const reportsInWatchlist = mockReports.map((r) => ({ ...r, watchlistId: "watchlist-123" }));
|
||||
mockReportRepo.findReportsByIds.mockResolvedValue(reportsInWatchlist);
|
||||
|
||||
await expect(
|
||||
addToWatchlistHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
reportIds: ["report-1", "report-2"],
|
||||
type: WatchlistType.EMAIL,
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
message: "All selected bookers are already in the watchlist",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("successful addition", () => {
|
||||
beforeEach(() => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should create new organization watchlist entry for EMAIL type and link report", async () => {
|
||||
mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]);
|
||||
mockWatchlistRepo.checkExists.mockResolvedValue(null);
|
||||
mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-new" });
|
||||
mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined);
|
||||
mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
|
||||
|
||||
const result = await addToWatchlistHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
reportIds: ["report-1"],
|
||||
type: WatchlistType.EMAIL,
|
||||
description: "Spam user",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.addedCount).toBe(1);
|
||||
expect(mockWatchlistRepo.checkExists).toHaveBeenCalledWith({
|
||||
type: WatchlistType.EMAIL,
|
||||
value: "spammer@example.com",
|
||||
organizationId: 100,
|
||||
});
|
||||
expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith({
|
||||
type: WatchlistType.EMAIL,
|
||||
value: "spammer@example.com",
|
||||
organizationId: 100,
|
||||
action: WatchlistAction.BLOCK,
|
||||
description: "Spam user",
|
||||
userId: 1,
|
||||
});
|
||||
expect(mockReportRepo.linkWatchlistToReport).toHaveBeenCalledWith({
|
||||
reportId: "report-1",
|
||||
watchlistId: "watchlist-new",
|
||||
});
|
||||
expect(mockReportRepo.updateReportStatus).toHaveBeenCalledWith({
|
||||
reportId: "report-1",
|
||||
status: "BLOCKED",
|
||||
organizationId: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("should create new organization watchlist entry for DOMAIN type and link report", async () => {
|
||||
mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]);
|
||||
mockWatchlistRepo.checkExists.mockResolvedValue(null);
|
||||
mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-domain" });
|
||||
mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined);
|
||||
mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
|
||||
|
||||
const result = await addToWatchlistHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
reportIds: ["report-1"],
|
||||
type: WatchlistType.DOMAIN,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith({
|
||||
type: WatchlistType.DOMAIN,
|
||||
value: "example.com",
|
||||
organizationId: 100,
|
||||
action: WatchlistAction.BLOCK,
|
||||
description: undefined,
|
||||
userId: 1,
|
||||
});
|
||||
expect(mockReportRepo.updateReportStatus).toHaveBeenCalledWith({
|
||||
reportId: "report-1",
|
||||
status: "BLOCKED",
|
||||
organizationId: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("should reuse existing organization watchlist entry when value already exists", async () => {
|
||||
mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]);
|
||||
mockWatchlistRepo.checkExists.mockResolvedValue({ id: "existing-watchlist" } as WatchlistEntry);
|
||||
mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined);
|
||||
mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
|
||||
|
||||
const result = await addToWatchlistHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
reportIds: ["report-1"],
|
||||
type: WatchlistType.EMAIL,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockWatchlistRepo.createEntry).not.toHaveBeenCalled();
|
||||
expect(mockReportRepo.linkWatchlistToReport).toHaveBeenCalledWith({
|
||||
reportId: "report-1",
|
||||
watchlistId: "existing-watchlist",
|
||||
});
|
||||
expect(mockReportRepo.updateReportStatus).toHaveBeenCalledWith({
|
||||
reportId: "report-1",
|
||||
status: "BLOCKED",
|
||||
organizationId: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("organization scoping", () => {
|
||||
beforeEach(() => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should pass organizationId to repository when finding reports", async () => {
|
||||
mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]);
|
||||
mockWatchlistRepo.checkExists.mockResolvedValue(null);
|
||||
mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-new" });
|
||||
mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined);
|
||||
mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
|
||||
|
||||
await addToWatchlistHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
reportIds: ["report-1"],
|
||||
type: WatchlistType.EMAIL,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockReportRepo.findReportsByIds).toHaveBeenCalledWith({
|
||||
reportIds: ["report-1"],
|
||||
organizationId: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("should pass organizationId when checking for existing watchlist entry", async () => {
|
||||
mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]);
|
||||
mockWatchlistRepo.checkExists.mockResolvedValue(null);
|
||||
mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-new" });
|
||||
mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined);
|
||||
mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
|
||||
|
||||
await addToWatchlistHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
reportIds: ["report-1"],
|
||||
type: WatchlistType.EMAIL,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockWatchlistRepo.checkExists).toHaveBeenCalledWith({
|
||||
type: WatchlistType.EMAIL,
|
||||
value: "spammer@example.com",
|
||||
organizationId: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import {
|
||||
normalizeEmail,
|
||||
extractDomainFromEmail,
|
||||
} from "@calcom/features/watchlist/lib/utils/normalization";
|
||||
import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
|
||||
import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole, WatchlistAction } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
import type { TAddToWatchlistInputSchema } from "./addToWatchlist.schema";
|
||||
|
||||
type AddToWatchlistOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TAddToWatchlistInputSchema;
|
||||
};
|
||||
|
||||
export const addToWatchlistHandler = async ({ ctx, input }: AddToWatchlistOptions) => {
|
||||
const { user } = ctx;
|
||||
|
||||
const organizationId = user.profile?.organizationId || user.organizationId;
|
||||
if (!organizationId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You must be part of an organization to add to watchlist",
|
||||
});
|
||||
}
|
||||
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
const hasPermission = await permissionCheckService.checkPermission({
|
||||
userId: user.id,
|
||||
teamId: organizationId,
|
||||
permission: "watchlist.create",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to add entries to the watchlist",
|
||||
});
|
||||
}
|
||||
|
||||
const bookingReportRepo = new PrismaBookingReportRepository(prisma);
|
||||
const watchlistRepo = new WatchlistRepository(prisma);
|
||||
|
||||
const validReports = await bookingReportRepo.findReportsByIds({
|
||||
reportIds: input.reportIds,
|
||||
organizationId,
|
||||
});
|
||||
|
||||
if (validReports.length !== input.reportIds.length) {
|
||||
const foundIds = validReports.map((r) => r.id);
|
||||
const missingIds = input.reportIds.filter((id) => !foundIds.includes(id));
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `Booking report(s) not found: ${missingIds.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
const reportsToAdd = validReports.filter((report) => !report.watchlistId);
|
||||
|
||||
if (reportsToAdd.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "All selected bookers are already in the watchlist",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
reportsToAdd.map(async (report) => {
|
||||
const value =
|
||||
input.type === "EMAIL"
|
||||
? normalizeEmail(report.bookerEmail)
|
||||
: extractDomainFromEmail(report.bookerEmail);
|
||||
|
||||
const existingWatchlist = await watchlistRepo.checkExists({
|
||||
type: input.type,
|
||||
value,
|
||||
organizationId,
|
||||
});
|
||||
|
||||
let watchlistId: string;
|
||||
|
||||
if (existingWatchlist) {
|
||||
watchlistId = existingWatchlist.id;
|
||||
} else {
|
||||
const newWatchlist = await watchlistRepo.createEntry({
|
||||
type: input.type,
|
||||
value,
|
||||
organizationId,
|
||||
action: WatchlistAction.BLOCK,
|
||||
description: input.description,
|
||||
userId: user.id,
|
||||
});
|
||||
watchlistId = newWatchlist.id;
|
||||
}
|
||||
|
||||
await bookingReportRepo.linkWatchlistToReport({
|
||||
reportId: report.id,
|
||||
watchlistId,
|
||||
});
|
||||
|
||||
await bookingReportRepo.updateReportStatus({
|
||||
reportId: report.id,
|
||||
status: "BLOCKED",
|
||||
organizationId,
|
||||
});
|
||||
|
||||
return { reportId: report.id, watchlistId, value };
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully added ${reportsToAdd.length} report(s) to watchlist`,
|
||||
addedCount: reportsToAdd.length,
|
||||
skippedCount: validReports.length - reportsToAdd.length,
|
||||
results: results.map((r) => ({ reportId: r.reportId, watchlistId: r.watchlistId })),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("already exists")) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This entry already exists in the watchlist for your organization",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default addToWatchlistHandler;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { WatchlistType } from "@calcom/prisma/enums";
|
||||
|
||||
export const ZAddToWatchlistInputSchema = z.object({
|
||||
reportIds: z.array(z.string().uuid()).min(1),
|
||||
type: z.nativeEnum(WatchlistType),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TAddToWatchlistInputSchema = z.infer<typeof ZAddToWatchlistInputSchema>;
|
||||
+8
-8
@@ -33,7 +33,7 @@ describe("deleteWatchlistEntryHandler", () => {
|
||||
};
|
||||
|
||||
const mockWatchlistRepo = {
|
||||
findEntryWithAudit: vi.fn(),
|
||||
findEntryWithAuditAndReports: vi.fn(),
|
||||
deleteEntry: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -75,12 +75,12 @@ describe("deleteWatchlistEntryHandler", () => {
|
||||
message: "You are not authorized to delete blocklist entries",
|
||||
});
|
||||
|
||||
expect(mockWatchlistRepo.findEntryWithAudit).not.toHaveBeenCalled();
|
||||
expect(mockWatchlistRepo.findEntryWithAuditAndReports).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should check permission with correct parameters", async () => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: [],
|
||||
});
|
||||
@@ -108,7 +108,7 @@ describe("deleteWatchlistEntryHandler", () => {
|
||||
});
|
||||
|
||||
it("should throw NOT_FOUND when entry does not exist", async () => {
|
||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: null,
|
||||
auditHistory: [],
|
||||
});
|
||||
@@ -129,7 +129,7 @@ describe("deleteWatchlistEntryHandler", () => {
|
||||
});
|
||||
|
||||
it("should throw FORBIDDEN when entry belongs to different organization", async () => {
|
||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: { ...mockEntry, organizationId: 999 },
|
||||
auditHistory: [],
|
||||
});
|
||||
@@ -156,7 +156,7 @@ describe("deleteWatchlistEntryHandler", () => {
|
||||
});
|
||||
|
||||
it("should successfully delete entry when authorized", async () => {
|
||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: [],
|
||||
});
|
||||
@@ -175,7 +175,7 @@ describe("deleteWatchlistEntryHandler", () => {
|
||||
});
|
||||
|
||||
it("should pass correct userId to deleteEntry", async () => {
|
||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: [],
|
||||
});
|
||||
@@ -191,4 +191,4 @@ describe("deleteWatchlistEntryHandler", () => {
|
||||
expect(mockWatchlistRepo.deleteEntry).toHaveBeenCalledWith("entry-123", 42);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ export const deleteWatchlistEntryHandler = async ({ ctx, input }: DeleteWatchlis
|
||||
|
||||
const watchlistRepo = new WatchlistRepository(prisma);
|
||||
|
||||
const { entry } = await watchlistRepo.findEntryWithAudit(input.id);
|
||||
const { entry } = await watchlistRepo.findEntryWithAuditAndReports(input.id);
|
||||
|
||||
if (!entry) {
|
||||
throw new TRPCError({
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
|
||||
import { MembershipRole, BookingReportReason } from "@calcom/prisma/enums";
|
||||
|
||||
import { dismissBookingReportHandler } from "./dismissBookingReport.handler";
|
||||
|
||||
vi.mock("@calcom/features/pbac/services/permission-check.service");
|
||||
vi.mock("@calcom/lib/server/repository/bookingReport");
|
||||
|
||||
describe("dismissBookingReportHandler", () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
email: "admin@example.com",
|
||||
organizationId: 100,
|
||||
profile: null,
|
||||
};
|
||||
|
||||
const mockReport = {
|
||||
id: "report-1",
|
||||
bookingUid: "booking-uid-1",
|
||||
bookerEmail: "spammer@example.com",
|
||||
reportedById: 1,
|
||||
reason: BookingReportReason.SPAM,
|
||||
description: null,
|
||||
cancelled: false,
|
||||
createdAt: new Date(),
|
||||
watchlistId: null,
|
||||
reporter: { id: 1, name: "Admin", email: "admin@example.com" },
|
||||
booking: {
|
||||
id: 1,
|
||||
uid: "booking-uid-1",
|
||||
title: "Test Booking",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
},
|
||||
watchlist: null,
|
||||
};
|
||||
|
||||
const mockPermissionCheckService = {
|
||||
checkPermission: vi.fn(),
|
||||
};
|
||||
|
||||
const mockReportRepo = {
|
||||
findReportsByIds: vi.fn(),
|
||||
updateReportStatus: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(PermissionCheckService).mockImplementation(
|
||||
() => mockPermissionCheckService as InstanceType<typeof PermissionCheckService>
|
||||
);
|
||||
vi.mocked(PrismaBookingReportRepository).mockImplementation(
|
||||
() => mockReportRepo as InstanceType<typeof PrismaBookingReportRepository>
|
||||
);
|
||||
});
|
||||
|
||||
describe("access control", () => {
|
||||
it("should throw FORBIDDEN when user is not part of an organization", async () => {
|
||||
const userWithoutOrg = { ...mockUser, organizationId: null, profile: null };
|
||||
|
||||
await expect(
|
||||
dismissBookingReportHandler({
|
||||
ctx: { user: userWithoutOrg },
|
||||
input: { reportId: "report-1" },
|
||||
})
|
||||
).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
code: "FORBIDDEN",
|
||||
message: "You must be part of an organization to dismiss booking reports",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw UNAUTHORIZED when user lacks permission", async () => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
dismissBookingReportHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: { reportId: "report-1" },
|
||||
})
|
||||
).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to dismiss booking reports",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should check for watchlist.update permission", async () => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
mockReportRepo.findReportsByIds.mockResolvedValue([mockReport]);
|
||||
mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
|
||||
|
||||
await dismissBookingReportHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: { reportId: "report-1" },
|
||||
});
|
||||
|
||||
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
|
||||
userId: 1,
|
||||
teamId: 100,
|
||||
permission: "watchlist.update",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("report verification", () => {
|
||||
beforeEach(() => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should throw NOT_FOUND when report does not exist", async () => {
|
||||
mockReportRepo.findReportsByIds.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
dismissBookingReportHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: { reportId: "non-existent-report" },
|
||||
})
|
||||
).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
code: "NOT_FOUND",
|
||||
message: "Booking report not found",
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockReportRepo.findReportsByIds).toHaveBeenCalledWith({
|
||||
reportIds: ["non-existent-report"],
|
||||
organizationId: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw BAD_REQUEST when report already has a watchlist entry", async () => {
|
||||
const blockedReport = { ...mockReport, watchlistId: "watchlist-123" };
|
||||
mockReportRepo.findReportsByIds.mockResolvedValue([blockedReport]);
|
||||
|
||||
await expect(
|
||||
dismissBookingReportHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: { reportId: "report-1" },
|
||||
})
|
||||
).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Cannot dismiss a report that has already been added to the blocklist",
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockReportRepo.updateReportStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("successful dismissal", () => {
|
||||
beforeEach(() => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
mockReportRepo.findReportsByIds.mockResolvedValue([mockReport]);
|
||||
mockReportRepo.updateReportStatus.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("should successfully dismiss a booking report after verifying it belongs to organization", async () => {
|
||||
const result = await dismissBookingReportHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: { reportId: "report-1" },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockReportRepo.findReportsByIds).toHaveBeenCalledWith({
|
||||
reportIds: ["report-1"],
|
||||
organizationId: 100,
|
||||
});
|
||||
expect(mockReportRepo.updateReportStatus).toHaveBeenCalledWith({
|
||||
reportId: "report-1",
|
||||
status: "DISMISSED",
|
||||
organizationId: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
import type { TDismissBookingReportInputSchema } from "./dismissBookingReport.schema";
|
||||
|
||||
type DismissBookingReportOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TDismissBookingReportInputSchema;
|
||||
};
|
||||
|
||||
export const dismissBookingReportHandler = async ({ ctx, input }: DismissBookingReportOptions) => {
|
||||
const { user } = ctx;
|
||||
|
||||
const organizationId = user.profile?.organizationId || user.organizationId;
|
||||
if (!organizationId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You must be part of an organization to dismiss booking reports",
|
||||
});
|
||||
}
|
||||
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
const hasPermission = await permissionCheckService.checkPermission({
|
||||
userId: user.id,
|
||||
teamId: organizationId,
|
||||
permission: "watchlist.update",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to dismiss booking reports",
|
||||
});
|
||||
}
|
||||
|
||||
const bookingReportRepo = new PrismaBookingReportRepository(prisma);
|
||||
|
||||
const reports = await bookingReportRepo.findReportsByIds({
|
||||
reportIds: [input.reportId],
|
||||
organizationId,
|
||||
});
|
||||
|
||||
if (reports.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Booking report not found",
|
||||
});
|
||||
}
|
||||
|
||||
const report = reports[0];
|
||||
|
||||
if (report.watchlistId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Cannot dismiss a report that has already been added to the blocklist",
|
||||
});
|
||||
}
|
||||
|
||||
await bookingReportRepo.updateReportStatus({
|
||||
reportId: input.reportId,
|
||||
status: "DISMISSED",
|
||||
organizationId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export default dismissBookingReportHandler;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZDismissBookingReportInputSchema = z.object({
|
||||
reportId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export type TDismissBookingReportInputSchema = z.infer<typeof ZDismissBookingReportInputSchema>;
|
||||
+15
-15
@@ -61,7 +61,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
||||
};
|
||||
|
||||
const mockWatchlistRepo = {
|
||||
findEntryWithAudit: vi.fn(),
|
||||
findEntryWithAuditAndReports: vi.fn(),
|
||||
};
|
||||
|
||||
const mockUserRepo = {
|
||||
@@ -94,7 +94,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
||||
|
||||
it("should use profile.organizationId when available", async () => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: { ...mockEntry, organizationId: 200 },
|
||||
auditHistory: [],
|
||||
});
|
||||
@@ -135,12 +135,12 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
||||
message: "You are not authorized to view blocklist entries",
|
||||
});
|
||||
|
||||
expect(mockWatchlistRepo.findEntryWithAudit).not.toHaveBeenCalled();
|
||||
expect(mockWatchlistRepo.findEntryWithAuditAndReports).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should check permission with correct parameters", async () => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: [],
|
||||
});
|
||||
@@ -167,7 +167,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
||||
});
|
||||
|
||||
it("should throw NOT_FOUND when entry does not exist", async () => {
|
||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: null,
|
||||
auditHistory: [],
|
||||
});
|
||||
@@ -186,7 +186,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
||||
});
|
||||
|
||||
it("should throw FORBIDDEN when entry belongs to different organization", async () => {
|
||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: { ...mockEntry, organizationId: 999 },
|
||||
auditHistory: [],
|
||||
});
|
||||
@@ -211,7 +211,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
||||
});
|
||||
|
||||
it("should return entry and audit history when authorized", async () => {
|
||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: mockAuditHistory,
|
||||
});
|
||||
@@ -226,11 +226,11 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
||||
|
||||
expect(result.entry).toEqual(mockEntry);
|
||||
expect(result.auditHistory).toHaveLength(2);
|
||||
expect(mockWatchlistRepo.findEntryWithAudit).toHaveBeenCalledWith("entry-123");
|
||||
expect(mockWatchlistRepo.findEntryWithAuditAndReports).toHaveBeenCalledWith("entry-123");
|
||||
});
|
||||
|
||||
it("should enrich audit history with user details", async () => {
|
||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: mockAuditHistory,
|
||||
});
|
||||
@@ -254,7 +254,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
||||
{ ...mockAuditHistory[1], changedByUserId: 1 },
|
||||
];
|
||||
|
||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: auditWithDuplicates,
|
||||
});
|
||||
@@ -278,7 +278,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
||||
{ ...mockAuditHistory[1], changedByUserId: 2 },
|
||||
];
|
||||
|
||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: auditWithNulls,
|
||||
});
|
||||
@@ -302,7 +302,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
||||
{ ...mockAuditHistory[1], changedByUserId: 2 },
|
||||
];
|
||||
|
||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: auditWithUndefined,
|
||||
});
|
||||
@@ -321,7 +321,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
||||
});
|
||||
|
||||
it("should not call findUsersByIds when audit history is empty", async () => {
|
||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: [],
|
||||
});
|
||||
@@ -338,7 +338,7 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
||||
});
|
||||
|
||||
it("should handle when user is not found in user map", async () => {
|
||||
mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: [mockAuditHistory[0]],
|
||||
});
|
||||
@@ -354,4 +354,4 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
||||
expect(result.auditHistory[0].changedByUser).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ export const getWatchlistEntryDetailsHandler = async ({ ctx, input }: GetWatchli
|
||||
const watchlistRepo = new WatchlistRepository(prisma);
|
||||
const userRepo = new UserRepository(prisma);
|
||||
|
||||
const result = await watchlistRepo.findEntryWithAudit(input.id);
|
||||
const result = await watchlistRepo.findEntryWithAuditAndReports(input.id);
|
||||
|
||||
if (!result.entry) {
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
|
||||
import { MembershipRole, BookingReportReason, BookingReportStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { listBookingReportsHandler } from "./listBookingReports.handler";
|
||||
|
||||
vi.mock("@calcom/features/pbac/services/permission-check.service");
|
||||
vi.mock("@calcom/lib/server/repository/bookingReport");
|
||||
|
||||
describe("listBookingReportsHandler (Organization)", () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
email: "admin@example.com",
|
||||
organizationId: 100,
|
||||
profile: null,
|
||||
};
|
||||
|
||||
const mockReportData = {
|
||||
rows: [
|
||||
{
|
||||
id: "report-1",
|
||||
bookingUid: "uid-1",
|
||||
bookerEmail: "user1@example.com",
|
||||
reportedById: 1,
|
||||
reason: BookingReportReason.SPAM,
|
||||
description: null,
|
||||
cancelled: false,
|
||||
createdAt: new Date("2025-01-01"),
|
||||
status: BookingReportStatus.PENDING,
|
||||
watchlistId: null,
|
||||
reporter: { id: 1, name: "Admin", email: "admin@example.com" },
|
||||
booking: {
|
||||
id: 1,
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
title: "Test Booking",
|
||||
uid: "uid-1",
|
||||
},
|
||||
watchlist: null,
|
||||
},
|
||||
{
|
||||
id: "report-2",
|
||||
bookingUid: "uid-2",
|
||||
bookerEmail: "user2@example.com",
|
||||
reportedById: 1,
|
||||
reason: BookingReportReason.DONT_KNOW_PERSON,
|
||||
description: "Unknown person",
|
||||
cancelled: true,
|
||||
createdAt: new Date("2025-01-02"),
|
||||
status: BookingReportStatus.BLOCKED,
|
||||
watchlistId: "watchlist-123",
|
||||
reporter: { id: 1, name: "Admin", email: "admin@example.com" },
|
||||
booking: {
|
||||
id: 2,
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
title: "Test Booking 2",
|
||||
uid: "uid-2",
|
||||
},
|
||||
watchlist: {
|
||||
id: "watchlist-123",
|
||||
type: "EMAIL" as const,
|
||||
value: "user2@example.com",
|
||||
action: "REPORT" as const,
|
||||
description: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: { totalRowCount: 2 },
|
||||
};
|
||||
|
||||
const mockPermissionCheckService = {
|
||||
checkPermission: vi.fn(),
|
||||
};
|
||||
|
||||
const mockReportRepo = {
|
||||
findAllReportedBookings: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(PermissionCheckService).mockImplementation(
|
||||
() => mockPermissionCheckService as InstanceType<typeof PermissionCheckService>
|
||||
);
|
||||
vi.mocked(PrismaBookingReportRepository).mockImplementation(
|
||||
() => mockReportRepo as InstanceType<typeof PrismaBookingReportRepository>
|
||||
);
|
||||
});
|
||||
|
||||
describe("access control", () => {
|
||||
it("should throw FORBIDDEN when user is not part of an organization", async () => {
|
||||
await expect(
|
||||
listBookingReportsHandler({
|
||||
ctx: { user: { ...mockUser, organizationId: undefined, profile: null } },
|
||||
input: {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
message: "You must be part of an organization to view booking reports",
|
||||
});
|
||||
|
||||
expect(mockPermissionCheckService.checkPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw UNAUTHORIZED when user lacks permission", async () => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
listBookingReportsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to view booking reports",
|
||||
});
|
||||
|
||||
expect(mockReportRepo.findAllReportedBookings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should check permission with correct parameters", async () => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData);
|
||||
|
||||
await listBookingReportsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
|
||||
userId: 1,
|
||||
teamId: 100,
|
||||
permission: "watchlist.read",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("successful listing", () => {
|
||||
beforeEach(() => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should return paginated booking reports for organization", async () => {
|
||||
mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData);
|
||||
|
||||
const result = await listBookingReportsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockReportData);
|
||||
expect(result.rows).toHaveLength(2);
|
||||
expect(result.meta.totalRowCount).toBe(2);
|
||||
expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({
|
||||
organizationId: 100,
|
||||
skip: 0,
|
||||
take: 10,
|
||||
searchTerm: undefined,
|
||||
filters: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should pass search term to repository", async () => {
|
||||
mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData);
|
||||
|
||||
await listBookingReportsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
searchTerm: "spam@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({
|
||||
organizationId: 100,
|
||||
skip: 0,
|
||||
take: 10,
|
||||
searchTerm: "spam@example.com",
|
||||
filters: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should pass filters to repository", async () => {
|
||||
mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData);
|
||||
|
||||
const filters = {
|
||||
reason: [BookingReportReason.SPAM, BookingReportReason.DONT_KNOW_PERSON],
|
||||
cancelled: true,
|
||||
hasWatchlist: false,
|
||||
};
|
||||
|
||||
await listBookingReportsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
limit: 20,
|
||||
offset: 10,
|
||||
filters,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({
|
||||
organizationId: 100,
|
||||
skip: 10,
|
||||
take: 20,
|
||||
searchTerm: undefined,
|
||||
filters,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return rows and meta from repository", async () => {
|
||||
const customData = {
|
||||
rows: [mockReportData.rows[0]],
|
||||
meta: { totalRowCount: 1 },
|
||||
};
|
||||
mockReportRepo.findAllReportedBookings.mockResolvedValue(customData);
|
||||
|
||||
const result = await listBookingReportsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.meta.totalRowCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pagination", () => {
|
||||
beforeEach(() => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should correctly pass limit and offset to repository", async () => {
|
||||
mockReportRepo.findAllReportedBookings.mockResolvedValue({
|
||||
rows: [],
|
||||
meta: { totalRowCount: 0 },
|
||||
});
|
||||
|
||||
await listBookingReportsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
limit: 50,
|
||||
offset: 100,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({
|
||||
organizationId: 100,
|
||||
skip: 100,
|
||||
take: 50,
|
||||
searchTerm: undefined,
|
||||
filters: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle first page correctly", async () => {
|
||||
mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData);
|
||||
|
||||
await listBookingReportsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
limit: 25,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({
|
||||
organizationId: 100,
|
||||
skip: 0,
|
||||
take: 25,
|
||||
searchTerm: undefined,
|
||||
filters: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle pagination with search and filters", async () => {
|
||||
mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData);
|
||||
|
||||
await listBookingReportsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
limit: 15,
|
||||
offset: 30,
|
||||
searchTerm: "test@example.com",
|
||||
filters: { hasWatchlist: false },
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({
|
||||
organizationId: 100,
|
||||
skip: 30,
|
||||
take: 15,
|
||||
searchTerm: "test@example.com",
|
||||
filters: { hasWatchlist: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("organization scoping", () => {
|
||||
beforeEach(() => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should only return reports from user's organization", async () => {
|
||||
mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData);
|
||||
|
||||
await listBookingReportsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({
|
||||
organizationId: 100,
|
||||
skip: 0,
|
||||
take: 10,
|
||||
searchTerm: undefined,
|
||||
filters: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
import type { TListBookingReportsInputSchema } from "./listBookingReports.schema";
|
||||
|
||||
type ListBookingReportsOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TListBookingReportsInputSchema;
|
||||
};
|
||||
|
||||
export const listBookingReportsHandler = async ({ ctx, input }: ListBookingReportsOptions) => {
|
||||
const { user } = ctx;
|
||||
|
||||
const organizationId = user.profile?.organizationId || user.organizationId;
|
||||
if (!organizationId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You must be part of an organization to view booking reports",
|
||||
});
|
||||
}
|
||||
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
const hasPermission = await permissionCheckService.checkPermission({
|
||||
userId: user.id,
|
||||
teamId: organizationId,
|
||||
permission: "watchlist.read",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to view booking reports",
|
||||
});
|
||||
}
|
||||
|
||||
const bookingReportRepo = new PrismaBookingReportRepository(prisma);
|
||||
|
||||
const result = await bookingReportRepo.findAllReportedBookings({
|
||||
organizationId,
|
||||
skip: input.offset,
|
||||
take: input.limit,
|
||||
searchTerm: input.searchTerm,
|
||||
filters: input.filters,
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export default listBookingReportsHandler;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { BookingReportReason, BookingReportStatus } from "@calcom/prisma/enums";
|
||||
|
||||
export const ZListBookingReportsInputSchema = z.object({
|
||||
limit: z.number().min(1).max(100).default(25),
|
||||
offset: z.number().min(0).default(0),
|
||||
searchTerm: z.string().optional(),
|
||||
filters: z
|
||||
.object({
|
||||
reason: z.array(z.nativeEnum(BookingReportReason)).optional(),
|
||||
cancelled: z.boolean().optional(),
|
||||
hasWatchlist: z.boolean().optional(),
|
||||
status: z.array(z.nativeEnum(BookingReportStatus)).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type TListBookingReportsInputSchema = z.infer<typeof ZListBookingReportsInputSchema>;
|
||||
@@ -22,10 +22,12 @@ const generateCheckoutSession = async ({
|
||||
teamSlug,
|
||||
teamName,
|
||||
userId,
|
||||
isOnboarding,
|
||||
}: {
|
||||
teamSlug: string;
|
||||
teamName: string;
|
||||
userId: number;
|
||||
isOnboarding?: boolean;
|
||||
}) => {
|
||||
if (!IS_TEAM_BILLING_ENABLED) {
|
||||
console.info("Team billing is disabled, not generating a checkout session.");
|
||||
@@ -36,6 +38,7 @@ const generateCheckoutSession = async ({
|
||||
teamSlug,
|
||||
teamName,
|
||||
userId,
|
||||
isOnboarding,
|
||||
});
|
||||
|
||||
if (!checkoutSession.url)
|
||||
@@ -48,7 +51,7 @@ const generateCheckoutSession = async ({
|
||||
|
||||
export const createHandler = async ({ ctx, input }: CreateOptions) => {
|
||||
const { user } = ctx;
|
||||
const { slug, name } = input;
|
||||
const { slug, name, isOnboarding } = input;
|
||||
const isOrgChildTeam = !!user.profile?.organizationId;
|
||||
|
||||
// For orgs we want to create teams under the org
|
||||
@@ -80,6 +83,7 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
|
||||
teamSlug: slug,
|
||||
teamName: name,
|
||||
userId: user.id,
|
||||
isOnboarding,
|
||||
});
|
||||
|
||||
// If there is a checkout session, return it. Otherwise, it means it's disabled.
|
||||
|
||||
@@ -10,6 +10,7 @@ export const ZCreateInputSchema = z.object({
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((v) => v || null),
|
||||
isOnboarding: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TCreateInputSchema = z.infer<typeof ZCreateInputSchema>;
|
||||
|
||||
@@ -95,11 +95,10 @@ const CheckboxField = forwardRef<HTMLInputElement, Props>(
|
||||
{descriptionAsSafeHtml ? (
|
||||
<span
|
||||
className={classNames(
|
||||
"text-default ml-2 text-sm",
|
||||
"text-default ml-2 text-sm [&_a]:text-blue-500",
|
||||
!label && "font-medium",
|
||||
rest.descriptionClassName
|
||||
)}
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: markdownToSafeHTML(descriptionAsSafeHtml),
|
||||
}}
|
||||
|
||||
+1
-14
@@ -5,7 +5,6 @@
|
||||
"ALLOWED_HOSTNAMES",
|
||||
"ANALYZE",
|
||||
"API_KEY_PREFIX",
|
||||
"APP_USER_NAME",
|
||||
"ATOMS_E2E_API_URL",
|
||||
"ATOMS_E2E_OAUTH_CLIENT_ID",
|
||||
"ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED",
|
||||
@@ -23,7 +22,6 @@
|
||||
"CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY",
|
||||
"CALCOM_CREDENTIAL_SYNC_ENDPOINT",
|
||||
"CALCOM_CREDENTIAL_SYNC_SECRET",
|
||||
"CALCOM_CREDENTIAL_SYNC_SECRET_HEADER_NAME",
|
||||
"CALCOM_ENV",
|
||||
"CALCOM_LICENSE_KEY",
|
||||
"CALCOM_QA_EMAIL",
|
||||
@@ -72,13 +70,11 @@
|
||||
"EMAIL_SERVER_PORT",
|
||||
"EMAIL_SERVER_USER",
|
||||
"EMAIL_SERVER",
|
||||
"ENABLE_EXPANDED_CACHE",
|
||||
"EXCHANGE_DEFAULT_EWS_URL",
|
||||
"FORMBRICKS_FEEDBACK_SURVEY_ID",
|
||||
"AVATARAPI_USERNAME",
|
||||
"AVATARAPI_PASSWORD",
|
||||
"GIPHY_API_KEY",
|
||||
"GITHUB_API_REPO_TOKEN",
|
||||
"GOOGLE_API_CREDENTIALS",
|
||||
"GOOGLE_LOGIN_ENABLED",
|
||||
"GOOGLE_WEBHOOK_TOKEN",
|
||||
@@ -98,9 +94,7 @@
|
||||
"MOCK_PAYMENT_APP_ENABLED",
|
||||
"MS_GRAPH_CLIENT_ID",
|
||||
"MS_GRAPH_CLIENT_SECRET",
|
||||
"NEXT_PUBLIC_API_URL",
|
||||
"NEXT_PUBLIC_APP_NAME",
|
||||
"NEXT_PUBLIC_AUTH_URL",
|
||||
"NEXT_PUBLIC_CALCOM_VERSION",
|
||||
"NEXT_PUBLIC_COMPANY_NAME",
|
||||
"NEXT_PUBLIC_CONSOLE_URL",
|
||||
@@ -117,17 +111,14 @@
|
||||
"NEXT_PUBLIC_SENDGRID_SENDER_NAME",
|
||||
"NEXT_PUBLIC_SENTRY_DSN",
|
||||
"NEXT_PUBLIC_SENTRY_DSN_CLIENT",
|
||||
"NEXT_PUBLIC_STRIPE_PRICING_TABLE_ID",
|
||||
"NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
|
||||
"NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE_MONTHLY",
|
||||
"NEXT_PUBLIC_STRIPE_PRICING_TABLE_PUBLISHABLE_KEY",
|
||||
"NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID",
|
||||
"ORG_MONTHLY_CREDITS",
|
||||
"NEXT_PUBLIC_PLAIN_CHAT_ID",
|
||||
"NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS",
|
||||
"NEXT_PUBLIC_BOOKER_NUMBER_OF_DAYS_TO_LOAD",
|
||||
"NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS",
|
||||
"NEXT_PUBLIC_SWAGGER_DOCS_URL",
|
||||
"NEXT_PUBLIC_TEAM_IMPERSONATION",
|
||||
"NEXT_PUBLIC_VERCEL_URL",
|
||||
"NEXT_PUBLIC_CAL_AI_PHONE_NUMBER_MONTHLY_PRICE",
|
||||
@@ -184,9 +175,6 @@
|
||||
"SLACK_SIGNING_SECRET",
|
||||
"STRIPE_CLIENT_ID",
|
||||
"STRIPE_PRIVATE_KEY",
|
||||
"STRIPE_PRODUCT_ID_ESSENTIALS",
|
||||
"STRIPE_PRODUCT_ID_SCALE",
|
||||
"STRIPE_PRODUCT_ID_STARTER",
|
||||
"STRIPE_WEBHOOK_SECRET",
|
||||
"STRIPE_WEBHOOK_SECRET_APPS",
|
||||
"STRIPE_WEBHOOK_SECRET_BILLING",
|
||||
@@ -359,8 +347,7 @@
|
||||
"NEXT_PUBLIC_SENTRY_DSN_CLIENT",
|
||||
"BUILD_STANDALONE",
|
||||
"NEXT_PUBLIC_API_HITPAY_PRODUCTION",
|
||||
"NEXT_PUBLIC_API_HITPAY_SANDBOX",
|
||||
"NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS"
|
||||
"NEXT_PUBLIC_API_HITPAY_SANDBOX"
|
||||
]
|
||||
},
|
||||
"@calcom/web#dx": {
|
||||
|
||||
@@ -2681,7 +2681,7 @@ __metadata:
|
||||
"@getalby/lightning-tools": ^5.1.2
|
||||
"@getalby/sdk": ^4.0.0
|
||||
"@webbtc/webln-types": ^3.0.0
|
||||
react-qr-code: ^2.0.12
|
||||
react-qr-code: ^2.0.18
|
||||
svix: ^0.85.1
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
@@ -41207,19 +41207,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-qr-code@npm:^2.0.12":
|
||||
version: 2.0.12
|
||||
resolution: "react-qr-code@npm:2.0.12"
|
||||
"react-qr-code@npm:^2.0.18":
|
||||
version: 2.0.18
|
||||
resolution: "react-qr-code@npm:2.0.18"
|
||||
dependencies:
|
||||
prop-types: ^15.8.1
|
||||
qr.js: 0.0.0
|
||||
peerDependencies:
|
||||
react: ^16.x || ^17.x || ^18.x
|
||||
react-native-svg: "*"
|
||||
peerDependenciesMeta:
|
||||
react-native-svg:
|
||||
optional: true
|
||||
checksum: b7bad40d7d5f04f7ff336ae499920dd5ea0e7616ed7fec5f849e54f8f26b5e4acd1ba8e69198be8617dc7fecdf36cf4302c99ff74885320b55bb95d19a09de7c
|
||||
react: "*"
|
||||
checksum: c90164aac6c124d6a7aba2d9426859072d2233bb8d06102d23ef0c1831048e94cebc7608b821ff2b79f3a1cadf454305c727fd3e1d7742fd71f816cdeac04053
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user