Restore team event creation
This commit is contained in:
@@ -5,12 +5,12 @@ import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import type { EventType } from "@calcom/prisma/client";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogClose, DialogContent, DialogFooter } from "@calcom/ui/components/dialog";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js/max";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { z } from "zod";
|
||||
import { useCreateEventType } from "~/event-types/hooks/useCreateEventType";
|
||||
|
||||
@@ -69,7 +69,7 @@ export function CreateEventTypeDialog({ profileOptions }: { profileOptions: Prof
|
||||
const orgBranding = null;
|
||||
|
||||
const {
|
||||
data: { teamId, eventPage: pageSlug },
|
||||
data: { teamId, eventPage: pageSlug, schedulingType: querySchedulingType },
|
||||
} = useTypedQuery(querySchema);
|
||||
|
||||
const teamProfile = profileOptions.find((profile) => profile.teamId === teamId);
|
||||
@@ -102,31 +102,85 @@ export function CreateEventTypeDialog({ profileOptions }: { profileOptions: Prof
|
||||
};
|
||||
|
||||
const { form, createMutation, isManagedEventType } = useCreateEventType(onSuccessMutation, onErrorMutation);
|
||||
const selectedSchedulingType = form.watch("schedulingType");
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamId) return;
|
||||
|
||||
form.setValue("teamId", teamId);
|
||||
form.setValue("schedulingType", querySchedulingType ?? SchedulingType.ROUND_ROBIN);
|
||||
}, [form, querySchedulingType, teamId]);
|
||||
|
||||
const urlPrefix = WEBSITE_URL;
|
||||
const schedulingOptions = [
|
||||
{
|
||||
value: SchedulingType.ROUND_ROBIN,
|
||||
label: t("round_robin"),
|
||||
description: t("round_robin_description"),
|
||||
},
|
||||
{
|
||||
value: SchedulingType.COLLECTIVE,
|
||||
label: t("collective"),
|
||||
description: t("collective_description"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
name="new"
|
||||
clearQueryParamsOnClose={["eventPage", "type", "description", "title", "length", "slug", "locations"]}>
|
||||
clearQueryParamsOnClose={[
|
||||
"eventPage",
|
||||
"type",
|
||||
"description",
|
||||
"title",
|
||||
"length",
|
||||
"slug",
|
||||
"locations",
|
||||
"schedulingType",
|
||||
]}>
|
||||
<DialogContent
|
||||
type="creation"
|
||||
enableOverflow
|
||||
title={teamId ? t("add_new_team_event_type") : t("add_new_event_type")}
|
||||
description={t("new_event_type_to_book_description")}>
|
||||
{teamId ? null : (
|
||||
<CreateEventTypeForm
|
||||
urlPrefix={urlPrefix}
|
||||
isPending={createMutation.isPending}
|
||||
form={form}
|
||||
isManagedEventType={isManagedEventType}
|
||||
handleSubmit={(values) => {
|
||||
createMutation.mutate(values);
|
||||
}}
|
||||
SubmitButton={SubmitButton}
|
||||
pageSlug={pageSlug}
|
||||
/>
|
||||
)}
|
||||
{teamId ? (
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{schedulingOptions.map((option) => {
|
||||
const isSelected = selectedSchedulingType === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`rounded-md border p-3 text-left transition ${
|
||||
isSelected ? "border-emphasis bg-emphasis text-emphasis" : "border-subtle hover:bg-muted"
|
||||
}`}
|
||||
onClick={() => {
|
||||
form.setValue("teamId", teamId, { shouldDirty: true });
|
||||
form.setValue("schedulingType", option.value, { shouldDirty: true });
|
||||
}}>
|
||||
<span className="block text-sm font-medium">{option.label}</span>
|
||||
<span className="mt-1 block text-xs text-subtle">{option.description}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<CreateEventTypeForm
|
||||
urlPrefix={urlPrefix}
|
||||
isPending={createMutation.isPending}
|
||||
form={form}
|
||||
isManagedEventType={isManagedEventType}
|
||||
handleSubmit={(values) => {
|
||||
createMutation.mutate({
|
||||
...values,
|
||||
...(teamId
|
||||
? { teamId, schedulingType: values.schedulingType ?? SchedulingType.ROUND_ROBIN }
|
||||
: {}),
|
||||
});
|
||||
}}
|
||||
SubmitButton={SubmitButton}
|
||||
pageSlug={teamProfile?.slug ?? pageSlug}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -45,7 +45,7 @@ const EventAvailabilityTab = dynamic(() =>
|
||||
import("./tabs/availability/EventAvailabilityTabWebWrapper").then((mod) => mod)
|
||||
);
|
||||
|
||||
const EventTeamAssignmentTab = dynamic(() => Promise.resolve((_props: Record<string, unknown>) => null));
|
||||
const EventTeamAssignmentTab = dynamic(() => import("./tabs/team/EventTeamAssignmentTab").then((mod) => mod));
|
||||
|
||||
const EventLimitsTab = dynamic(() => import("./tabs/limits/EventLimitsTabWebWrapper").then((mod) => mod));
|
||||
|
||||
@@ -61,7 +61,6 @@ const EventWebhooksTab = dynamic(() =>
|
||||
import("./tabs/webhooks/EventWebhooksTab").then((mod) => mod.EventWebhooksTab)
|
||||
);
|
||||
|
||||
|
||||
export type EventTypeWebWrapperProps = {
|
||||
id: number;
|
||||
data: RouterOutputs["viewer"]["eventTypes"]["get"];
|
||||
@@ -278,16 +277,7 @@ const EventTypeWeb = ({
|
||||
|
||||
const querySchema = z.object({
|
||||
tabName: z
|
||||
.enum([
|
||||
"setup",
|
||||
"availability",
|
||||
"team",
|
||||
"limits",
|
||||
"advanced",
|
||||
"recurring",
|
||||
"apps",
|
||||
"webhooks",
|
||||
])
|
||||
.enum(["setup", "availability", "team", "limits", "advanced", "recurring", "apps", "webhooks"])
|
||||
.optional()
|
||||
.default("setup"),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import type { EventTypeSetupProps, FormValues, Host } from "@calcom/features/eventtypes/lib/types";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
type EventTeamAssignmentTabProps = {
|
||||
orgId: number | null;
|
||||
teamMembers: EventTypeSetupProps["teamMembers"];
|
||||
team: EventTypeSetupProps["team"];
|
||||
eventType: EventTypeSetupProps["eventType"];
|
||||
};
|
||||
|
||||
type SearchMember = {
|
||||
userId: number;
|
||||
name: string | null;
|
||||
email: string;
|
||||
avatarUrl: string | null;
|
||||
defaultScheduleId: number | null;
|
||||
};
|
||||
|
||||
const hostDefaults = (member: SearchMember, schedulingType: SchedulingType | null): Host => ({
|
||||
userId: member.userId,
|
||||
isFixed: schedulingType === SchedulingType.COLLECTIVE,
|
||||
priority: 2,
|
||||
weight: 100,
|
||||
scheduleId: member.defaultScheduleId,
|
||||
groupId: null,
|
||||
});
|
||||
|
||||
export default function EventTeamAssignmentTab({
|
||||
teamMembers,
|
||||
team,
|
||||
eventType,
|
||||
}: EventTeamAssignmentTabProps) {
|
||||
const { t } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const watchedHosts = formMethods.watch("hosts") ?? [];
|
||||
const watchedGroups = formMethods.watch("hostGroups") ?? [];
|
||||
const schedulingType = formMethods.watch("schedulingType") ?? eventType.schedulingType;
|
||||
const isCollective = schedulingType === SchedulingType.COLLECTIVE;
|
||||
const selectedUserIds = useMemo(() => watchedHosts.map((host) => host.userId), [watchedHosts]);
|
||||
|
||||
const fallbackMembers = useMemo<SearchMember[]>(
|
||||
() =>
|
||||
teamMembers.map((member) => ({
|
||||
userId: member.id,
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
avatarUrl: member.avatarUrl,
|
||||
defaultScheduleId: member.defaultScheduleId,
|
||||
})),
|
||||
[teamMembers]
|
||||
);
|
||||
|
||||
const { data, isPending } = trpc.viewer.eventTypes.searchTeamMembers.useQuery(
|
||||
{
|
||||
teamId: team?.id ?? eventType.teamId ?? 0,
|
||||
search: search || undefined,
|
||||
limit: 20,
|
||||
memberUserIds: selectedUserIds.length ? selectedUserIds : undefined,
|
||||
},
|
||||
{ enabled: Boolean(team?.id ?? eventType.teamId) }
|
||||
);
|
||||
|
||||
const members = useMemo<SearchMember[]>(() => {
|
||||
const memberMap = new Map<number, SearchMember>();
|
||||
fallbackMembers.forEach((member) => memberMap.set(member.userId, member));
|
||||
data?.members.forEach((member) => {
|
||||
memberMap.set(member.userId, {
|
||||
userId: member.userId,
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
avatarUrl: member.avatarUrl,
|
||||
defaultScheduleId: member.defaultScheduleId,
|
||||
});
|
||||
});
|
||||
return Array.from(memberMap.values());
|
||||
}, [data?.members, fallbackMembers]);
|
||||
|
||||
const memberByUserId = useMemo(() => new Map(members.map((member) => [member.userId, member])), [members]);
|
||||
const availableMembers = members.filter((member) => !selectedUserIds.includes(member.userId));
|
||||
|
||||
const setHosts = (hosts: Host[]) => {
|
||||
formMethods.setValue("hosts", hosts, { shouldDirty: true });
|
||||
};
|
||||
|
||||
const updateHost = (userId: number, data: Partial<Host>) => {
|
||||
setHosts(watchedHosts.map((host) => (host.userId === userId ? { ...host, ...data } : host)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-md border border-subtle p-4">
|
||||
<label className="mb-2 block text-sm font-medium text-emphasis" htmlFor="team-member-search">
|
||||
{t("team_members")}
|
||||
</label>
|
||||
<input
|
||||
id="team-member-search"
|
||||
type="search"
|
||||
value={search}
|
||||
placeholder={t("search")}
|
||||
className="border-default bg-default text-default placeholder:text-muted focus:border-emphasis mb-3 h-9 w-full rounded-md border px-3 text-sm outline-none"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
<div className="max-h-64 overflow-y-auto rounded-md border border-subtle">
|
||||
{availableMembers.length ? (
|
||||
availableMembers.map((member) => (
|
||||
<div
|
||||
key={member.userId}
|
||||
className="flex items-center justify-between gap-3 border-subtle border-b px-3 py-2 last:border-b-0">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Avatar size="sm" imageSrc={member.avatarUrl} alt={member.name ?? member.email} />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-emphasis">
|
||||
{member.name ?? member.email}
|
||||
</p>
|
||||
<p className="truncate text-xs text-subtle">{member.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
StartIcon="plus"
|
||||
onClick={() => setHosts([...watchedHosts, hostDefaults(member, schedulingType)])}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="px-3 py-4 text-sm text-subtle">{isPending ? t("loading") : t("no_results")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-subtle">
|
||||
<div className="border-subtle border-b px-4 py-3">
|
||||
<h3 className="text-sm font-medium text-emphasis">{t("hosts")}</h3>
|
||||
</div>
|
||||
{watchedHosts.length ? (
|
||||
<div className="divide-subtle divide-y">
|
||||
{watchedHosts.map((host) => {
|
||||
const member = memberByUserId.get(host.userId);
|
||||
return (
|
||||
<div key={host.userId} className="space-y-3 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Avatar
|
||||
size="sm"
|
||||
imageSrc={member?.avatarUrl}
|
||||
alt={member?.name ?? member?.email ?? ""}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-emphasis">
|
||||
{member?.name ?? member?.email ?? host.userId}
|
||||
</p>
|
||||
{member?.email ? (
|
||||
<p className="truncate text-xs text-subtle">{member.email}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
StartIcon="x"
|
||||
onClick={() => setHosts(watchedHosts.filter((item) => item.userId !== host.userId))}>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
|
||||
<label className="flex items-center gap-2 text-sm text-default">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isCollective || Boolean(host.isFixed)}
|
||||
disabled={isCollective}
|
||||
onChange={(event) => updateHost(host.userId, { isFixed: event.target.checked })}
|
||||
/>
|
||||
{t("fixed")}
|
||||
</label>
|
||||
|
||||
<label className="text-xs font-medium text-subtle">
|
||||
{t("priority")}
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={4}
|
||||
value={host.priority}
|
||||
disabled={isCollective}
|
||||
className="border-default bg-default text-default mt-1 h-9 w-full rounded-md border px-2 text-sm disabled:opacity-60"
|
||||
onChange={(event) =>
|
||||
updateHost(host.userId, { priority: Number(event.target.value || 0) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-xs font-medium text-subtle">
|
||||
{t("weight")}
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={host.weight}
|
||||
disabled={isCollective}
|
||||
className="border-default bg-default text-default mt-1 h-9 w-full rounded-md border px-2 text-sm disabled:opacity-60"
|
||||
onChange={(event) =>
|
||||
updateHost(host.userId, { weight: Number(event.target.value || 0) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-xs font-medium text-subtle">
|
||||
{t("schedule")}
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={host.scheduleId ?? ""}
|
||||
className="border-default bg-default text-default mt-1 h-9 w-full rounded-md border px-2 text-sm"
|
||||
onChange={(event) =>
|
||||
updateHost(host.userId, {
|
||||
scheduleId: event.target.value ? Number(event.target.value) : null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-xs font-medium text-subtle">
|
||||
{t("group")}
|
||||
<select
|
||||
value={host.groupId ?? ""}
|
||||
disabled={isCollective}
|
||||
className="border-default bg-default text-default mt-1 h-9 w-full rounded-md border px-2 text-sm disabled:opacity-60"
|
||||
onChange={(event) =>
|
||||
updateHost(host.userId, { groupId: event.target.value || null })
|
||||
}>
|
||||
<option value="">{t("none")}</option>
|
||||
{watchedGroups.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-4 py-5 text-sm text-subtle">{t("no_hosts_description")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { TRPCError } from "@trpc/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createHandler } from "./create.handler";
|
||||
import type { TCreateInputSchema } from "./create.schema";
|
||||
|
||||
const serviceMocks = vi.hoisted(() => ({
|
||||
eventTypeRepositoryConstructor: vi.fn(),
|
||||
membershipRepositoryConstructor: vi.fn(),
|
||||
membershipServiceConstructor: vi.fn(),
|
||||
createEventType: vi.fn(),
|
||||
hasAnyRole: vi.fn(),
|
||||
listAcceptedTeamMemberIds: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/app-store/_utils/getDefaultLocations", () => ({
|
||||
getDefaultLocations: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/eventtypes/repositories/eventTypeRepository", () => ({
|
||||
EventTypeRepository: vi.fn(function EventTypeRepository(prismaClient) {
|
||||
serviceMocks.eventTypeRepositoryConstructor(prismaClient);
|
||||
return {
|
||||
create: serviceMocks.createEventType,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/membership/repositories/MembershipRepository", () => ({
|
||||
MembershipRepository: vi.fn(function MembershipRepository(prismaClient) {
|
||||
serviceMocks.membershipRepositoryConstructor(prismaClient);
|
||||
return {
|
||||
listAcceptedTeamMemberIds: serviceMocks.listAcceptedTeamMemberIds,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/membership/services/membershipService", () => ({
|
||||
MembershipService: vi.fn(function MembershipService(repository) {
|
||||
serviceMocks.membershipServiceConstructor(repository);
|
||||
return {
|
||||
hasAnyRole: serviceMocks.hasAnyRole,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
const prisma = {
|
||||
organizationSettings: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const user = {
|
||||
id: 101,
|
||||
role: "USER",
|
||||
organizationId: null,
|
||||
organization: {
|
||||
isOrgAdmin: false,
|
||||
},
|
||||
profile: {
|
||||
id: 501,
|
||||
},
|
||||
metadata: {},
|
||||
email: "admin@example.com",
|
||||
};
|
||||
|
||||
const baseTeamInput = {
|
||||
title: "Team call",
|
||||
slug: "team-call",
|
||||
length: 30,
|
||||
hidden: false,
|
||||
teamId: 201,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
} satisfies TCreateInputSchema;
|
||||
|
||||
const buildCtx = () => ({
|
||||
user,
|
||||
prisma,
|
||||
});
|
||||
|
||||
describe("createHandler", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
serviceMocks.createEventType.mockResolvedValue({ id: 301, title: "Team call" });
|
||||
serviceMocks.hasAnyRole.mockResolvedValue(true);
|
||||
serviceMocks.listAcceptedTeamMemberIds.mockResolvedValue([101, 102]);
|
||||
prisma.organizationSettings.findUnique.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("creates a round robin team event with accepted member hosts as a team admin", async () => {
|
||||
await expect(
|
||||
createHandler({
|
||||
ctx: buildCtx(),
|
||||
input: {
|
||||
...baseTeamInput,
|
||||
hosts: [
|
||||
{ userId: 101, isFixed: false, priority: 1, weight: 100, scheduleId: 1001, groupId: null },
|
||||
{ userId: 102, isFixed: true, priority: 2, weight: 80, scheduleId: null, groupId: "group-a" },
|
||||
],
|
||||
} as TCreateInputSchema & {
|
||||
hosts: Array<{
|
||||
userId: number;
|
||||
isFixed: boolean;
|
||||
priority: number;
|
||||
weight: number;
|
||||
scheduleId: number | null;
|
||||
groupId: string | null;
|
||||
}>;
|
||||
},
|
||||
})
|
||||
).resolves.toEqual({ eventType: { id: 301, title: "Team call" } });
|
||||
|
||||
expect(serviceMocks.membershipRepositoryConstructor).toHaveBeenCalledWith(prisma);
|
||||
expect(serviceMocks.membershipServiceConstructor).toHaveBeenCalled();
|
||||
expect(serviceMocks.hasAnyRole).toHaveBeenCalledWith(201, 101, [
|
||||
MembershipRole.ADMIN,
|
||||
MembershipRole.OWNER,
|
||||
]);
|
||||
expect(serviceMocks.listAcceptedTeamMemberIds).toHaveBeenCalledWith({ teamId: 201 });
|
||||
expect(serviceMocks.createEventType).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Team call",
|
||||
team: { connect: { id: 201 } },
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
profileId: 501,
|
||||
hosts: {
|
||||
create: [
|
||||
{
|
||||
userId: 101,
|
||||
isFixed: false,
|
||||
priority: 1,
|
||||
weight: 100,
|
||||
scheduleId: 1001,
|
||||
groupId: null,
|
||||
},
|
||||
{
|
||||
userId: 102,
|
||||
isFixed: true,
|
||||
priority: 2,
|
||||
weight: 80,
|
||||
scheduleId: null,
|
||||
groupId: "group-a",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a collective team event as a team owner", async () => {
|
||||
serviceMocks.hasAnyRole.mockImplementation(async (_teamId, _userId, roles: MembershipRole[]) =>
|
||||
roles.includes(MembershipRole.OWNER)
|
||||
);
|
||||
|
||||
await createHandler({
|
||||
ctx: buildCtx(),
|
||||
input: {
|
||||
...baseTeamInput,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
hosts: [{ userId: 102, isFixed: false, priority: 0, weight: 50, scheduleId: null, groupId: null }],
|
||||
} as TCreateInputSchema & {
|
||||
hosts: Array<{
|
||||
userId: number;
|
||||
isFixed: boolean;
|
||||
priority: number;
|
||||
weight: number;
|
||||
scheduleId: number | null;
|
||||
groupId: string | null;
|
||||
}>;
|
||||
},
|
||||
});
|
||||
|
||||
expect(serviceMocks.createEventType).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
hosts: {
|
||||
create: [
|
||||
{
|
||||
userId: 102,
|
||||
isFixed: true,
|
||||
priority: 0,
|
||||
weight: 50,
|
||||
scheduleId: null,
|
||||
groupId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects team event creation by a regular team member", async () => {
|
||||
serviceMocks.hasAnyRole.mockResolvedValue(false);
|
||||
|
||||
await expect(createHandler({ ctx: buildCtx(), input: baseTeamInput })).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
} satisfies Partial<TRPCError>);
|
||||
|
||||
expect(serviceMocks.createEventType).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects host user IDs that are not accepted members of the team", async () => {
|
||||
serviceMocks.listAcceptedTeamMemberIds.mockResolvedValue([101]);
|
||||
|
||||
await expect(
|
||||
createHandler({
|
||||
ctx: buildCtx(),
|
||||
input: {
|
||||
...baseTeamInput,
|
||||
hosts: [{ userId: 999, isFixed: false, priority: 2, weight: 100, scheduleId: null, groupId: null }],
|
||||
} as TCreateInputSchema & {
|
||||
hosts: Array<{
|
||||
userId: number;
|
||||
isFixed: boolean;
|
||||
priority: number;
|
||||
weight: number;
|
||||
scheduleId: number | null;
|
||||
groupId: string | null;
|
||||
}>;
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
} satisfies Partial<TRPCError>);
|
||||
|
||||
expect(serviceMocks.createEventType).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { getDefaultLocations } from "@calcom/app-store/_utils/getDefaultLocations";
|
||||
import { DailyLocationType } from "@calcom/app-store/constants";
|
||||
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
|
||||
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
||||
import { MembershipService } from "@calcom/features/membership/services/membershipService";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
@@ -10,15 +12,17 @@ import type { z } from "zod";
|
||||
import type { TrpcSessionUser } from "../../../../types";
|
||||
import type { TCreateInputSchema } from "./create.schema";
|
||||
|
||||
class PermissionCheckService {
|
||||
constructor(_prisma?: unknown) {}
|
||||
async checkPermission(..._args: unknown[]) { return true; }
|
||||
async hasPermission(..._args: unknown[]) { return true; }
|
||||
async getTeamIdsWithPermission(..._args: unknown[]): Promise<number[]> { return []; }
|
||||
}
|
||||
|
||||
type EventTypeLocation = z.infer<typeof eventTypeLocations>[number];
|
||||
|
||||
type CreateHostInput = {
|
||||
userId: number;
|
||||
isFixed?: boolean;
|
||||
priority?: number | null;
|
||||
weight?: number | null;
|
||||
scheduleId?: number | null;
|
||||
groupId?: string | null;
|
||||
};
|
||||
|
||||
type SessionUser = NonNullable<TrpcSessionUser>;
|
||||
type User = {
|
||||
id: SessionUser["id"];
|
||||
@@ -50,24 +54,23 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
|
||||
locations: inputLocations,
|
||||
scheduleId,
|
||||
calVideoSettings,
|
||||
hosts,
|
||||
...rest
|
||||
} = input;
|
||||
} = input as TCreateInputSchema & { hosts?: CreateHostInput[] };
|
||||
|
||||
const userId = ctx.user.id;
|
||||
const isManagedEventType = schedulingType === SchedulingType.MANAGED;
|
||||
const isOrgAdmin = !!ctx.user?.organization?.isOrgAdmin;
|
||||
|
||||
const permissionService = new PermissionCheckService();
|
||||
const membershipRepository = new MembershipRepository(ctx.prisma);
|
||||
const membershipService = new MembershipService(membershipRepository);
|
||||
// Check if user has organization-level eventType.create permission (equivalent to org admin for event types)
|
||||
let hasOrgEventTypeCreatePermission = isOrgAdmin; // Default fallback
|
||||
|
||||
if (ctx.user.organizationId) {
|
||||
hasOrgEventTypeCreatePermission = await permissionService.checkPermission({
|
||||
userId,
|
||||
teamId: ctx.user.organizationId,
|
||||
permission: "eventType.create",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
if (!hasOrgEventTypeCreatePermission && ctx.user.organizationId) {
|
||||
hasOrgEventTypeCreatePermission = await membershipService.hasAnyRole(ctx.user.organizationId, userId, [
|
||||
MembershipRole.ADMIN,
|
||||
MembershipRole.OWNER,
|
||||
]);
|
||||
}
|
||||
|
||||
const locations: EventTypeLocation[] =
|
||||
@@ -103,13 +106,10 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
|
||||
if (teamId && schedulingType) {
|
||||
const isSystemAdmin = ctx.user.role === "ADMIN";
|
||||
|
||||
// Only check for team-level permissions - this will also check for membership
|
||||
const hasCreatePermission = await permissionService.checkPermission({
|
||||
userId,
|
||||
teamId,
|
||||
permission: "eventType.create",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
const hasCreatePermission = await membershipService.hasAnyRole(teamId, userId, [
|
||||
MembershipRole.ADMIN,
|
||||
MembershipRole.OWNER,
|
||||
]);
|
||||
|
||||
if (!isSystemAdmin && !hasOrgEventTypeCreatePermission && !hasCreatePermission) {
|
||||
// If none of the above conditions are met, the user is unauthorized.
|
||||
@@ -124,6 +124,26 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
|
||||
},
|
||||
};
|
||||
data.schedulingType = schedulingType;
|
||||
|
||||
if (hosts?.length) {
|
||||
const acceptedTeamMemberIds = await membershipRepository.listAcceptedTeamMemberIds({ teamId });
|
||||
const acceptedTeamMemberIdSet = new Set(acceptedTeamMemberIds);
|
||||
|
||||
if (!hosts.every((host) => acceptedTeamMemberIdSet.has(host.userId))) {
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
|
||||
data.hosts = {
|
||||
create: hosts.map((host) => ({
|
||||
userId: host.userId,
|
||||
isFixed: schedulingType === SchedulingType.COLLECTIVE || host.isFixed || false,
|
||||
priority: host.priority ?? 2,
|
||||
weight: host.weight ?? 100,
|
||||
scheduleId: host.scheduleId ?? null,
|
||||
groupId: host.groupId ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// If we are in an organization & they don't have org-level eventType.create permission & they are not creating an event on a teamID
|
||||
|
||||
@@ -99,6 +99,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
where: { id },
|
||||
select: {
|
||||
title: true,
|
||||
schedulingType: true,
|
||||
locations: true,
|
||||
description: true,
|
||||
seatsPerTimeSlot: true,
|
||||
@@ -454,6 +455,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
let hostLocationDeletions: { userId: number; eventTypeId: number }[] = [];
|
||||
|
||||
if (teamId && hosts) {
|
||||
const effectiveSchedulingType = data.schedulingType ?? eventType.schedulingType;
|
||||
// check if all hosts can be assigned (memberships that have accepted invite)
|
||||
const teamMemberIds = await membershipRepo.listAcceptedTeamMemberIds({ teamId });
|
||||
const teamMemberIdSet = new Set(teamMemberIds);
|
||||
@@ -499,7 +501,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
};
|
||||
} = {
|
||||
userId: host.userId,
|
||||
isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed || false,
|
||||
isFixed: effectiveSchedulingType === SchedulingType.COLLECTIVE || host.isFixed || false,
|
||||
priority: host.priority ?? 2,
|
||||
weight: host.weight ?? 100,
|
||||
groupId: host.groupId,
|
||||
@@ -544,7 +546,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
};
|
||||
};
|
||||
} = {
|
||||
isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed,
|
||||
isFixed: effectiveSchedulingType === SchedulingType.COLLECTIVE || host.isFixed,
|
||||
priority: host.priority ?? 2,
|
||||
weight: host.weight ?? 100,
|
||||
scheduleId: host.scheduleId === undefined ? undefined : host.scheduleId,
|
||||
|
||||
Reference in New Issue
Block a user