feat: Internal Notes for bookings (#18804)

* migration + migrate booking limit page to team settings

* new pages

* use settings toggle for displaying content

* get current team presets

* update mutation

* i18n for settings page

* remove redudant await

* cancelation UI for select

* handle other selection

* extract into a util

* inital cancelation reason flow

* fix i18n to be more readable

* add cancel reason logic

* nits

* improve copy

* ensure is teamMember and not isAdmin for getting internalNotes

* improvie spacing between elements

* fix types

* more type fixes

* correct types

* Update packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts

* use input component and remove mb-2 to allign with icon button

* Update packages/trpc/server/routers/viewer/teams/updateInternalNotesPresets.handler.ts

Co-authored-by: Eunjae Lee <hey@eunjae.dev>

* nits

* type fixs

* fix type

* fix type

* fix nullable option lost in merge

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
This commit is contained in:
sean-brydon
2025-01-24 09:26:52 +00:00
committed by GitHub
co-authored by Eunjae Lee
parent 1fd4c3fd4c
commit 656d6dc195
22 changed files with 683 additions and 22 deletions
@@ -1,5 +1,5 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import { PageProps } from "app/_types";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
@@ -1,7 +1,6 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import { PageProps } from "app/_types";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
@@ -1,5 +1,5 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import { PageProps as ServerPageProps } from "app/_types";
import type { PageProps as ServerPageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
@@ -1,23 +1,22 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import TeamBookingLimitsView from "@calcom/features/ee/teams/pages/team-booking-limits-view";
import TeamSettingsView from "@calcom/features/ee/teams/pages/team-settings-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("booking_limits"),
(t) => t("booking_limits_team_description")
(t) => t("settings"),
(t) => t("team_settings_description")
);
const Page = async () => {
const t = await getTranslate();
return (
<SettingsHeader
title={t("booking_limits")}
description={t("booking_limits_team_description")}
title={t("settings")}
description={t("team_settings_description")}
borderInShellHeader={false}>
<TeamBookingLimitsView />
<TeamSettingsView />
</SettingsHeader>
);
};
+93 -3
View File
@@ -5,7 +5,63 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRefreshData } from "@calcom/lib/hooks/useRefreshData";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import type { RecurringEvent } from "@calcom/types/Calendar";
import { Button, Icon, Label, TextArea } from "@calcom/ui";
import { Button, Icon, Label, TextArea, Select } from "@calcom/ui";
interface InternalNotePresetsSelectProps {
internalNotePresets: { id: number; name: string }[];
onPresetSelect: (
option: {
value: number | string;
label: string;
} | null
) => void;
}
const InternalNotePresetsSelect = ({
internalNotePresets,
onPresetSelect,
}: InternalNotePresetsSelectProps) => {
const { t } = useLocale();
const [showOtherInput, setShowOtherInput] = useState(false);
if (!internalNotePresets?.length) {
return null;
}
const handleSelectChange = (option: { value: number | string; label: string } | null) => {
if (option?.value === "other") {
setShowOtherInput(true);
} else {
setShowOtherInput(false);
onPresetSelect && onPresetSelect(option);
}
};
return (
<div className="mb-4 flex flex-col">
<Label>{t("internal_booking_note")}</Label>
<Select
className="mb-2"
options={[
...internalNotePresets?.map((preset) => ({
label: preset.name,
value: preset.id,
})),
{ label: t("other"), value: "other" },
]}
onChange={handleSelectChange}
placeholder={t("internal_booking_note")}
/>
{showOtherInput && (
<TextArea
rows={3}
placeholder={t("internal_booking_note_description")}
onChange={(e) => onPresetSelect?.({ value: "other", label: e.target.value })}
/>
)}
</div>
);
};
type Props = {
booking: {
@@ -19,6 +75,7 @@ type Props = {
};
recurringEvent: RecurringEvent | null;
team?: string | null;
teamId?: number;
setIsCancellationMode: (value: boolean) => void;
theme: string | null;
allRemainingBookings: boolean;
@@ -34,17 +91,25 @@ type Props = {
eventType: unknown;
};
isHost: boolean;
internalNotePresets: { id: number; name: string; cancellationReason: string | null }[];
};
export default function CancelBooking(props: Props) {
const [cancellationReason, setCancellationReason] = useState<string>("");
const { t } = useLocale();
const refreshData = useRefreshData();
const { booking, allRemainingBookings, seatReferenceUid, bookingCancelledEventProps, currentUserEmail } =
props;
const {
booking,
allRemainingBookings,
seatReferenceUid,
bookingCancelledEventProps,
currentUserEmail,
teamId,
} = props;
const [loading, setLoading] = useState(false);
const telemetry = useTelemetry();
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
const [internalNote, setInternalNote] = useState<{ id: number; name: string } | null>(null);
const cancelBookingRef = useCallback((node: HTMLTextAreaElement) => {
if (node !== null) {
@@ -71,7 +136,31 @@ export default function CancelBooking(props: Props) {
)}
{!error && (
<div className="mt-5 sm:mt-6">
{props.isHost && teamId && (
<>
<InternalNotePresetsSelect
internalNotePresets={props.internalNotePresets}
onPresetSelect={(option) => {
if (!option) return;
if (option.value === "other") {
setInternalNote({ id: -1, name: option.label });
} else {
const foundInternalNote = props.internalNotePresets.find(
(preset) => preset.id === Number(option.value)
);
if (foundInternalNote) {
setInternalNote(foundInternalNote);
setCancellationReason(foundInternalNote.cancellationReason || "");
}
}
}}
/>
</>
)}
<Label>{props.isHost ? t("cancellation_reason_host") : t("cancellation_reason")}</Label>
<TextArea
data-testid="cancel_reason"
ref={cancelBookingRef}
@@ -113,6 +202,7 @@ export default function CancelBooking(props: Props) {
// @NOTE: very important this shouldn't cancel with number ID use uid instead
seatReferenceUid,
cancelledBy: currentUserEmail,
internalNote: internalNote,
}),
headers: {
"Content-Type": "application/json",
+1
View File
@@ -60,6 +60,7 @@ export const getEventTypesFromDB = async (id: number) => {
},
team: {
select: {
id: true,
slug: true,
name: true,
hideBranding: true,
@@ -193,6 +193,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}
const { currentOrgDomain } = orgDomainConfig(context.req);
async function getInternalNotePresets(teamId: number) {
return await prisma.internalNotePreset.findMany({
where: {
teamId,
},
select: {
id: true,
name: true,
cancellationReason: true,
},
});
}
return {
props: {
orgSlug: currentOrgDomain,
@@ -210,6 +224,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
requiresLoginToUpdate,
rescheduledToUid,
isLoggedInUserHost,
internalNotePresets: eventType?.team?.id ? await getInternalNotePresets(eventType.team.id) : [],
},
};
}
@@ -771,6 +771,7 @@ export default function Success(props: PageProps) {
profile={{ name: props.profile.name, slug: props.profile.slug }}
recurringEvent={eventType.recurringEvent}
team={eventType?.team?.name}
teamId={eventType?.team?.id}
setIsCancellationMode={setIsCancellationMode}
theme={isSuccessBookingPage ? props.profile.theme : "light"}
allRemainingBookings={allRemainingBookings}
@@ -778,6 +779,7 @@ export default function Success(props: PageProps) {
bookingCancelledEventProps={bookingCancelledEventProps}
currentUserEmail={currentUserEmail}
isHost={isHost}
internalNotePresets={props.internalNotePresets}
/>
</>
))}
@@ -54,7 +54,7 @@
"cancellation_reason": "Reason for cancellation (optional)",
"cancellation_reason_host": "Reason for cancellation",
"cancellation_reason_placeholder": "Why are you cancelling?",
"notify_attendee_cancellation_reason_warning":"Please note that the cancellation reason will be visible to the attendee.",
"notify_attendee_cancellation_reason_warning":"Cancellation reason will be shared with guests",
"rejection_reason_placeholder": "Why are you rejecting?",
"rejection_reason": "Reason for rejecting",
"rejection_reason_title": "Reject the booking request?",
@@ -2666,6 +2666,7 @@
"x_marked_as_no_show": "{{x}} marked as no-show",
"x_unmarked_as_no_show": "{{x}} unmarked as no-show",
"team_select_info": "triggers for all team event types and all team members' personal event types",
"team_settings_description":"Manage settings for you team",
"no_show_updated": "No-show status updated for attendees",
"email_copied": "Email copied",
"phone_number_copied": "Phone Number Copied",
@@ -2930,6 +2931,13 @@
"managed_users_description": "See all the managed users created by your OAuth client",
"select_oAuth_client": "Select Oauth Client",
"on_every_instance": "On every instance",
"internal_booking_note": "Internal Note",
"internal_note_cancellation_reason": "Reason shared with customer (optional)",
"internal_booking_note_description": "This note will be visible to only members of your team",
"internal_note_presets":"Internal Cancelation Notes Presets",
"internal_note_presets_description" : "Create presets for internal notes that can be applied to bookings",
"internal_note_presets_updated_successfully" : "Internal note presets updated successfully",
"add_preset" : "Add Preset",
"authorize": "Authorize",
"next": "Next",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
@@ -35,6 +35,7 @@ import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer
import type { CalendarEvent } from "@calcom/types/Calendar";
import { getAllCredentials } from "./getAllCredentialsForUsersOnEvent/getAllCredentials";
import { handleInternalNote } from "./handleInternalNote";
import cancelAttendeeSeat from "./handleSeats/cancel/cancelAttendeeSeat";
const log = logger.getSubLogger({ prefix: ["handleCancelBooking"] });
@@ -137,9 +138,11 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine
});
}
export type BookingToDelete = Awaited<ReturnType<typeof getBookingToDelete>>;
export type CustomRequest = NextApiRequest & {
userId?: number;
bookingToDelete?: Awaited<ReturnType<typeof getBookingToDelete>>;
bookingToDelete?: BookingToDelete;
platformClientId?: string;
platformRescheduleUrl?: string;
platformCancelUrl?: string;
@@ -164,6 +167,7 @@ async function handler(req: CustomRequest) {
seatReferenceUid,
cancelledBy,
cancelSubsequentBookings,
internalNote,
} = bookingCancelInput.parse(req.body);
req.bookingToDelete = await getBookingToDelete(id, uid);
const {
@@ -560,6 +564,15 @@ async function handler(req: CustomRequest) {
log.error("An error occurred when deleting workflow reminders and webhook triggers", error);
});
if (internalNote && teamId) {
await handleInternalNote({
internalNote,
booking: bookingToDelete,
userId,
teamId: teamId,
});
}
const prismaPromises: Promise<unknown>[] = [bookingReferenceDeletes];
try {
@@ -0,0 +1,82 @@
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import { type BookingToDelete } from "./handleCancelBooking";
type InternalNote = {
id: number;
name: string;
value?: string;
};
export async function handleInternalNote({
internalNote,
booking,
userId,
teamId,
}: {
internalNote: InternalNote;
booking: BookingToDelete;
userId: number;
teamId: number;
}) {
const userIsHost = booking?.eventType?.hosts.find((host) => {
if (host.user.id === userId) return true;
});
const userIsOwnerOfEventType = booking?.eventType?.owner?.id === userId;
if (!userIsHost && !userIsOwnerOfEventType) {
throw new HttpError({
statusCode: 403,
message: "You do not have permission to add an internal note to this booking.",
});
}
// "Other"
if (internalNote.id === -1) {
return prisma.bookingInternalNote.create({
data: {
booking: {
connect: {
id: booking.id,
},
},
text: internalNote.value,
createdBy: {
connect: {
id: userId,
},
},
},
});
}
// "Preset"
await prisma.internalNotePreset.findFirstOrThrow({
where: {
teamId: teamId,
id: internalNote.id,
},
});
return prisma.bookingInternalNote.create({
data: {
notePreset: {
connect: {
id: internalNote.id,
},
},
createdBy: {
connect: {
id: userId,
},
},
booking: {
connect: {
id: booking.id,
},
},
},
});
}
@@ -0,0 +1,185 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useMemo } from "react";
import { useForm, useFieldArray, Controller } from "react-hook-form";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Button, Form, SettingsToggle, showToast, Icon, Input } from "@calcom/ui";
interface ProfileViewProps {
team: RouterOutputs["viewer"]["teams"]["get"];
}
const OTHER_FIELD_ID = -1;
const InternalNotePresetsView = ({ team }: ProfileViewProps) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const { data: _loadedPresets } = trpc.viewer.teams.getInternalNotesPresets.useQuery({
teamId: team?.id as number,
});
const loadedPresets = useMemo(() => {
return (_loadedPresets ?? []).map((preset) => ({
...preset,
cancellationReason: preset.cancellationReason ?? undefined,
}));
}, [_loadedPresets]);
const hasExistingPresets = loadedPresets.length > 0;
type FormValues = {
presets: { id: number; name: string; cancellationReason?: string | undefined }[];
};
const form = useForm<FormValues>({
values: {
presets: loadedPresets,
},
});
const { fields, append, remove, replace } = useFieldArray({
control: form.control,
name: "presets",
});
const addNewPreset = () => {
append({ id: OTHER_FIELD_ID, name: "" });
};
const updatePresetsMutation = trpc.viewer.teams.updateInternalNotesPresets.useMutation({
onSuccess: () => {
showToast(t("internal_note_presets_updated_successfully"), "success");
utils.viewer.teams.getInternalNotesPresets.invalidate();
},
onError: (error) => {
showToast(error.message || t("something_went_wrong"), "error");
},
});
const onSubmit = async (data: FormValues) => {
if (!team?.id) return;
updatePresetsMutation.mutate({
teamId: team.id,
presets: data.presets,
});
};
const [animateRef] = useAutoAnimate<HTMLDivElement>();
const isAdmin =
team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN);
if (!isAdmin) {
return (
<div className="border-subtle rounded-md border p-5">
<span className="text-default text-sm">{t("only_owner_change")}</span>
</div>
);
}
return (
<Form form={form} handleSubmit={(e) => onSubmit(e)}>
<Controller
name="presets"
render={({ field: { value } }) => {
const isChecked = hasExistingPresets || (value && value.length > 0);
return (
<SettingsToggle
toggleSwitchAtTheEnd={true}
labelClassName="text-sm"
title={t("internal_note_presets")}
description={t("internal_note_presets_description")}
checked={isChecked}
childrenClassName="lg:ml-0"
onCheckedChange={async (active) => {
if (active && !value?.length) {
append({ id: OTHER_FIELD_ID, name: "" });
} else {
replace([]);
if (!active && team?.id && hasExistingPresets) {
updatePresetsMutation.mutate({
teamId: team.id,
presets: [],
});
}
}
}}
switchContainerClassName={classNames(
"border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6",
isChecked && "rounded-b-none"
)}>
<div className="border-subtle border border-y-0 p-6">
<div className="flex flex-col space-y-4" ref={animateRef}>
{fields.map((field, index) => (
<div key={field.id} className="space-y-2">
<div className="flex items-center space-x-2">
<Controller
name={`presets.${index}.name`}
control={form.control}
render={({ field }) => (
<Input
type="text"
{...field}
placeholder={t("internal_booking_note")}
className="!mb-0"
/>
)}
/>
<Button
type="button"
color="destructive"
variant="icon"
onClick={() => remove(index)}
disabled={fields.length === 1}>
<Icon name="trash" className="h-4 w-5" />
</Button>
</div>
<div className="flex items-center gap-2">
<Icon name="corner-down-right" className="h-4 w-4" />
<Controller
name={`presets.${index}.cancellationReason`}
control={form.control}
render={({ field }) => (
<Input
type="text"
{...field}
placeholder={t("internal_note_cancellation_reason")}
className="!mb-0"
/>
)}
/>
</div>
</div>
))}
</div>
<Button
type="button"
color="minimal"
StartIcon="plus"
onClick={addNewPreset}
className="mt-4">
{t("add_preset")}
</Button>
</div>
<SectionBottomActions align="end">
<Button type="submit" color="primary" loading={updatePresetsMutation.isPending}>
{t("update")}
</Button>
</SectionBottomActions>
</SettingsToggle>
);
}}
/>
</Form>
);
};
export default InternalNotePresetsView;
@@ -16,6 +16,8 @@ import type { RouterOutputs } from "@calcom/trpc/react";
import type { IntervalLimit } from "@calcom/types/Calendar";
import { Button, CheckboxField, Form, SettingsToggle, showToast } from "@calcom/ui";
import { default as InternalNotePresetsView } from "../components/InternalNotePresetsView";
type ProfileViewProps = { team: RouterOutputs["viewer"]["teams"]["get"] };
const BookingLimitsView = ({ team }: ProfileViewProps) => {
@@ -136,7 +138,7 @@ const BookingLimitsView = ({ team }: ProfileViewProps) => {
);
};
const BookingLimitsViewWrapper = () => {
const TeamSettingsViewWrapper = () => {
const router = useRouter();
const params = useParamsWithFallback();
@@ -164,7 +166,12 @@ const BookingLimitsViewWrapper = () => {
if (!team) return null;
return <BookingLimitsView team={team} />;
return (
<>
<BookingLimitsView team={team} />
<InternalNotePresetsView team={team} />
</>
);
};
export default BookingLimitsViewWrapper;
export default TeamSettingsViewWrapper;
@@ -394,8 +394,8 @@ const TeamListCollapsible = () => {
</>
) : null}
<VerticalTabItem
name={t("booking_limits")}
href={`/settings/teams/${team.id}/bookingLimits`}
name={t("settings")}
href={`/settings/teams/${team.id}/settings`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
@@ -0,0 +1,52 @@
-- CreateTable
CREATE TABLE "InternalNotePreset" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"cancellationReason" TEXT,
"teamId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "InternalNotePreset_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BookingInternalNote" (
"id" SERIAL NOT NULL,
"notePresetId" INTEGER,
"text" TEXT,
"bookingId" INTEGER NOT NULL,
"createdById" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BookingInternalNote_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "InternalNotePreset_teamId_idx" ON "InternalNotePreset"("teamId");
-- CreateIndex
CREATE UNIQUE INDEX "InternalNotePreset_teamId_name_key" ON "InternalNotePreset"("teamId", "name");
-- CreateIndex
CREATE INDEX "BookingInternalNote_bookingId_idx" ON "BookingInternalNote"("bookingId");
-- CreateIndex
CREATE UNIQUE INDEX "BookingInternalNote_bookingId_notePresetId_key" ON "BookingInternalNote"("bookingId", "notePresetId");
-- CreateIndex
CREATE INDEX "Impersonations_impersonatedUserId_idx" ON "Impersonations"("impersonatedUserId");
-- CreateIndex
CREATE INDEX "Impersonations_impersonatedById_idx" ON "Impersonations"("impersonatedById");
-- AddForeignKey
ALTER TABLE "InternalNotePreset" ADD CONSTRAINT "InternalNotePreset_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingInternalNote" ADD CONSTRAINT "BookingInternalNote_notePresetId_fkey" FOREIGN KEY ("notePresetId") REFERENCES "InternalNotePreset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingInternalNote" ADD CONSTRAINT "BookingInternalNote_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingInternalNote" ADD CONSTRAINT "BookingInternalNote_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+40 -1
View File
@@ -366,6 +366,7 @@ model User {
updatedTranslations EventTypeTranslation[] @relation("UpdatedEventTypeTranslations")
createdWatchlists Watchlist[] @relation("CreatedWatchlists")
updatedWatchlists Watchlist[] @relation("UpdatedWatchlists")
BookingInternalNote BookingInternalNote[]
@@unique([email])
@@unique([email, username])
@@ -473,7 +474,8 @@ model Team {
/// @zod.custom(imports.intervalLimitsType)
bookingLimits Json?
includeManagedEventsInLimits Boolean @default(false)
includeManagedEventsInLimits Boolean @default(false)
internalNotePresets InternalNotePreset[]
@@unique([slug, parentId])
@@index([parentId])
@@ -668,6 +670,7 @@ model Booking {
// Ah, made a typo here. Should have been routedFromRoutingFormRe"s"ponse. Live with it :(
routedFromRoutingFormReponse App_RoutingForms_FormResponse?
assignmentReason AssignmentReason[]
internalNote BookingInternalNote[]
@@index([eventTypeId])
@@index([userId])
@@ -870,6 +873,9 @@ model Impersonations {
impersonatedBy User @relation("impersonated_by_user", fields: [impersonatedById], references: [id], onDelete: Cascade)
impersonatedUserId Int
impersonatedById Int
@@index([impersonatedUserId])
@@index([impersonatedById])
}
model ApiKey {
@@ -1824,3 +1830,36 @@ model App_RoutingForms_IncompleteBookingActions {
enabled Boolean @default(true)
credentialId Int?
}
model InternalNotePreset {
id Int @id @default(autoincrement())
name String
cancellationReason String?
team Team @relation(fields: [teamId], references: [id])
teamId Int
createdAt DateTime @default(now())
BookingInternalNote BookingInternalNote[]
@@unique([teamId, name])
@@index([teamId])
}
model BookingInternalNote {
id Int @id @default(autoincrement())
notePreset InternalNotePreset? @relation(fields: [notePresetId], references: [id], onDelete: Cascade)
notePresetId Int?
text String?
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
bookingId Int
createdBy User @relation(fields: [createdById], references: [id])
createdById Int
createdAt DateTime @default(now())
@@unique([bookingId, notePresetId])
@@index([bookingId])
}
+8
View File
@@ -370,6 +370,14 @@ export const bookingCancelSchema = z.object({
cancellationReason: z.string().optional(),
seatReferenceUid: z.string().optional(),
cancelledBy: z.string().email({ message: "Invalid email" }).optional(),
internalNote: z
.object({
id: z.number(),
name: z.string(),
cancellationReason: z.string().optional(),
})
.optional()
.nullable(),
});
export const bookingCancelAttendeeSeatSchema = z.object({
@@ -9,6 +9,7 @@ import { ZCreateInviteInputSchema } from "./createInvite.schema";
import { ZDeleteInputSchema } from "./delete.schema";
import { ZDeleteInviteInputSchema } from "./deleteInvite.schema";
import { ZGetSchema } from "./get.schema";
import { ZGetInternalNotesPresetsInputSchema } from "./getInternalNotesPresets.schema";
import { ZGetMemberAvailabilityInputSchema } from "./getMemberAvailability.schema";
import { ZGetMembershipbyUserInputSchema } from "./getMembershipbyUser.schema";
import { ZGetUserConnectedAppsInputSchema } from "./getUserConnectedApps.schema";
@@ -28,6 +29,7 @@ import { ZRoundRobinManualReassignInputSchema } from "./roundRobin/roundRobinMan
import { ZRoundRobinReassignInputSchema } from "./roundRobin/roundRobinReassign.schema";
import { ZSetInviteExpirationInputSchema } from "./setInviteExpiration.schema";
import { ZUpdateInputSchema } from "./update.schema";
import { ZUpdateInternalNotesPresetsInputSchema } from "./updateInternalNotesPresets.schema";
import { ZUpdateMembershipInputSchema } from "./updateMembership.schema";
const NAMESPACE = "teams";
@@ -222,6 +224,24 @@ export const viewerTeamsRouter = router({
);
return handler(opts);
}),
getInternalNotesPresets: authedProcedure
.input(ZGetInternalNotesPresetsInputSchema)
.query(async ({ ctx, input }) => {
const handler = await importHandler(
namespaced("getInternalNotesPresets"),
() => import("./getInternalNotesPresets.handler")
);
return handler({ ctx, input });
}),
updateInternalNotesPresets: authedProcedure
.input(ZUpdateInternalNotesPresetsInputSchema)
.mutation(async ({ ctx, input }) => {
const handler = await importHandler(
namespaced("updateInternalNotesPresets"),
() => import("./updateInternalNotesPresets.handler")
);
return handler({ ctx, input });
}),
hasActiveTeamPlan: authedProcedure.query(async (opts) => {
const handler = await importHandler(
namespaced("hasActiveTeamPlan"),
@@ -0,0 +1,33 @@
import { isTeamMember } from "@calcom/lib/server/queries/teams";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import type { TGetInternalNotesPresetsInputSchema } from "./getInternalNotesPresets.schema";
type UpdateMembershipOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TGetInternalNotesPresetsInputSchema;
};
export const getInternalNotesPresetsHandler = async ({ ctx, input }: UpdateMembershipOptions) => {
if (!(await isTeamMember(ctx.user?.id, input.teamId))) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return await prisma.internalNotePreset.findMany({
where: {
teamId: input.teamId,
},
select: {
id: true,
name: true,
cancellationReason: true,
},
});
};
export default getInternalNotesPresetsHandler;
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZGetInternalNotesPresetsInputSchema = z.object({
teamId: z.number(),
});
export type TGetInternalNotesPresetsInputSchema = z.infer<typeof ZGetInternalNotesPresetsInputSchema>;
@@ -0,0 +1,87 @@
import { isTeamAdmin } from "@calcom/lib/server/queries/teams";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import type { TUpdateInternalNotesPresetsInputSchema } from "./updateInternalNotesPresets.schema";
type UpdateInternalNotesPresetsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TUpdateInternalNotesPresetsInputSchema;
};
export const updateInternalNotesPresetsHandler = async ({
ctx,
input,
}: UpdateInternalNotesPresetsOptions) => {
const isOrgAdmin = ctx.user?.organization?.isOrgAdmin;
if (!isOrgAdmin) {
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
// Get existing presets to handle deletions
const existingPresets = await prisma.internalNotePreset.findMany({
where: {
teamId: input.teamId,
},
select: {
id: true,
},
});
const existingIds = existingPresets.map((preset) => preset.id);
const updatedIds = input.presets.map((preset) => preset.id).filter((id): id is number => id !== undefined);
// Find IDs to delete (existing IDs that aren't in the update)
const idsToDelete = existingIds.filter((id) => !updatedIds.includes(id));
// Delete removed presets
if (idsToDelete.length > 0) {
await prisma.internalNotePreset.deleteMany({
where: {
id: {
in: idsToDelete,
},
teamId: input.teamId,
},
});
}
// Update or create presets
const updatedPresets = await Promise.all(
input.presets.map((preset) => {
if (preset.id && preset.id !== -1) {
// Update existing preset
return prisma.internalNotePreset.update({
where: {
id: preset.id,
teamId: input.teamId,
},
data: {
name: preset.name,
cancellationReason: preset.cancellationReason,
},
});
} else {
// Create new preset
return prisma.internalNotePreset.create({
data: {
name: preset.name,
cancellationReason: preset.cancellationReason,
teamId: input.teamId,
},
});
}
})
);
return updatedPresets;
};
export default updateInternalNotesPresetsHandler;
@@ -0,0 +1,14 @@
import { z } from "zod";
export const ZUpdateInternalNotesPresetsInputSchema = z.object({
teamId: z.number(),
presets: z.array(
z.object({
id: z.number(),
name: z.string(),
cancellationReason: z.string().optional(),
})
),
});
export type TUpdateInternalNotesPresetsInputSchema = z.infer<typeof ZUpdateInternalNotesPresetsInputSchema>;