diff --git a/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.integration-test.ts b/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.integration-test.ts index 3b5adf3d80..c665a8e853 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.integration-test.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.integration-test.ts @@ -1,23 +1,25 @@ -import { describe, it, expect, afterAll, beforeAll } from "vitest"; - import prisma from "@calcom/prisma"; -import type { User, Team, EventType } from "@calcom/prisma/client"; -import { MembershipRole } from "@calcom/prisma/enums"; - +import type { EventType, Team, User } from "@calcom/prisma/client"; +import { MembershipRole, SchedulingType } from "@calcom/prisma/enums"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { listWithTeamHandler } from "./listWithTeam.handler"; let user1: User; let user2: User; +let user3: User; let team1: Team; let team2: Team; +let team3: Team; let eventType1: EventType; let eventType2: EventType; let eventType3: EventType; let eventType4: EventType; +let managedParent: EventType; +let managedChild: EventType; +let teamEventNoUserId: EventType; describe("listWithTeamHandler", () => { beforeAll(async () => { - // Create users, teams and event types const timestamp = Date.now(); user1 = await prisma.user.create({ data: { @@ -33,6 +35,13 @@ describe("listWithTeamHandler", () => { name: "Test User 2", }, }); + user3 = await prisma.user.create({ + data: { + username: `testuser-lwt-3-${timestamp}`, + email: `testuser-lwt-3-${timestamp}@example.com`, + name: "Test User 3", + }, + }); team1 = await prisma.team.create({ data: { @@ -55,9 +64,23 @@ describe("listWithTeamHandler", () => { }, }); + team3 = await prisma.team.create({ + data: { + name: "Team 3 lwt", + slug: `team-3-lwt-${timestamp}`, + members: { + create: { + userId: user1.id, + role: MembershipRole.ADMIN, + accepted: true, + }, + }, + }, + }); + eventType1 = await prisma.eventType.create({ data: { - title: "User 1 Event", + title: "User 1 Individual Event", slug: `user1-event-lwt-${timestamp}`, length: 30, userId: user1.id, @@ -66,7 +89,7 @@ describe("listWithTeamHandler", () => { eventType2 = await prisma.eventType.create({ data: { - title: "Team 1 Event", + title: "Team 1 Event (user1 owner + member)", slug: `team1-event-lwt-${timestamp}`, length: 30, teamId: team1.id, @@ -76,7 +99,7 @@ describe("listWithTeamHandler", () => { eventType3 = await prisma.eventType.create({ data: { - title: "User 2 Event", + title: "User 2 Individual Event", slug: `user2-event-lwt-${timestamp}`, length: 30, userId: user2.id, @@ -85,42 +108,70 @@ describe("listWithTeamHandler", () => { eventType4 = await prisma.eventType.create({ data: { - title: "Team 2 Event", + title: "Team 2 Event (user2 owner, NOT member)", slug: `team2-event-lwt-${timestamp}`, length: 30, teamId: team2.id, userId: user2.id, }, }); + + managedParent = await prisma.eventType.create({ + data: { + title: "Managed Parent Event", + slug: `managed-parent-lwt-${timestamp}`, + length: 45, + teamId: team3.id, + schedulingType: SchedulingType.MANAGED, + }, + }); + + managedChild = await prisma.eventType.create({ + data: { + title: "Managed Child Event", + slug: `managed-child-lwt-${timestamp}`, + length: 45, + userId: user1.id, + parentId: managedParent.id, + }, + }); + + teamEventNoUserId = await prisma.eventType.create({ + data: { + title: "Team 3 Event (no userId)", + slug: `team3-nouserid-lwt-${timestamp}`, + length: 60, + teamId: team3.id, + }, + }); }); afterAll(async () => { try { - if (eventType1?.id || eventType2?.id || eventType3?.id || eventType4?.id) { + const eventTypeIds = [ + eventType1?.id, + eventType2?.id, + eventType3?.id, + eventType4?.id, + managedChild?.id, + managedParent?.id, + teamEventNoUserId?.id, + ].filter(Boolean); + if (eventTypeIds.length > 0) { await prisma.eventType.deleteMany({ - where: { - id: { - in: [eventType1?.id, eventType2?.id, eventType3?.id, eventType4?.id].filter(Boolean), - }, - }, + where: { id: { in: eventTypeIds } }, }); } - if (team1?.id || team2?.id) { + const teamIds = [team1?.id, team2?.id, team3?.id].filter(Boolean); + if (teamIds.length > 0) { await prisma.team.deleteMany({ - where: { - id: { - in: [team1?.id, team2?.id].filter(Boolean), - }, - }, + where: { id: { in: teamIds } }, }); } - if (user1?.id || user2?.id) { + const userIds = [user1?.id, user2?.id, user3?.id].filter(Boolean); + if (userIds.length > 0) { await prisma.user.deleteMany({ - where: { - id: { - in: [user1?.id, user2?.id].filter(Boolean), - }, - }, + where: { id: { in: userIds } }, }); } } catch (error) { @@ -128,32 +179,162 @@ describe("listWithTeamHandler", () => { } }); - it("should return user's own event types and event types of teams they are a member of", async () => { + it("should return user's own event types and team event types they are a member of", async () => { const result = await listWithTeamHandler({ - ctx: { - // we only need the id from the user object - user: { id: user1.id } as any, - }, + ctx: { user: { id: user1.id } }, }); - expect(result).toHaveLength(2); - const resultIds = result.map((e) => e.id); expect(resultIds).toContain(eventType1.id); expect(resultIds).toContain(eventType2.id); - const eventType1Result = result.find((e) => e.id === eventType1.id); - expect(eventType1Result).toBeDefined(); - expect(eventType1Result?.title).toBe(eventType1.title); - expect(eventType1Result?.slug).toBe(eventType1.slug); - expect(eventType1Result?.team).toBeNull(); + const individualResult = result.find((e) => e.id === eventType1.id); + expect(individualResult).toBeDefined(); + expect(individualResult?.title).toBe(eventType1.title); + expect(individualResult?.slug).toBe(eventType1.slug); + expect(individualResult?.team).toBeNull(); - const eventType2Result = result.find((e) => e.id === eventType2.id); - expect(eventType2Result).toBeDefined(); - expect(eventType2Result?.title).toBe(eventType2.title); - expect(eventType2Result?.slug).toBe(eventType2.slug); - expect(eventType2Result?.team).toBeDefined(); - expect(eventType2Result?.team?.id).toBe(team1.id); - expect(eventType2Result?.team?.name).toBe(team1.name); + const teamResult = result.find((e) => e.id === eventType2.id); + expect(teamResult).toBeDefined(); + expect(teamResult?.title).toBe(eventType2.title); + expect(teamResult?.slug).toBe(eventType2.slug); + expect(teamResult?.team).toBeDefined(); + expect(teamResult?.team?.id).toBe(team1.id); + expect(teamResult?.team?.name).toBe(team1.name); + }); + + it("should not return event types belonging to other users", async () => { + const result = await listWithTeamHandler({ + ctx: { user: { id: user1.id } }, + }); + + const resultIds = result.map((e) => e.id); + expect(resultIds).not.toContain(eventType3.id); + expect(resultIds).not.toContain(eventType4.id); + }); + + it("should return team event types where userId is set but user has no membership", async () => { + const result = await listWithTeamHandler({ + ctx: { user: { id: user2.id } }, + }); + + const resultIds = result.map((e) => e.id); + expect(resultIds).toContain(eventType3.id); + expect(resultIds).toContain(eventType4.id); + + const teamEventResult = result.find((e) => e.id === eventType4.id); + expect(teamEventResult).toBeDefined(); + expect(teamEventResult?.team?.id).toBe(team2.id); + expect(teamEventResult?.team?.name).toBe(team2.name); + }); + + it("should not duplicate events when user is both owner and team member", async () => { + const result = await listWithTeamHandler({ + ctx: { user: { id: user1.id } }, + }); + + const eventType2Occurrences = result.filter((e) => e.id === eventType2.id); + expect(eventType2Occurrences).toHaveLength(1); + }); + + it("should return managed event type children with userId set", async () => { + const result = await listWithTeamHandler({ + ctx: { user: { id: user1.id } }, + }); + + const resultIds = result.map((e) => e.id); + expect(resultIds).toContain(managedChild.id); + + const managedChildResult = result.find((e) => e.id === managedChild.id); + expect(managedChildResult).toBeDefined(); + expect(managedChildResult?.title).toBe(managedChild.title); + expect(managedChildResult?.team).toBeNull(); + }); + + it("should return team event types without userId via membership", async () => { + const result = await listWithTeamHandler({ + ctx: { user: { id: user1.id } }, + }); + + const resultIds = result.map((e) => e.id); + expect(resultIds).toContain(teamEventNoUserId.id); + + const noUserIdResult = result.find((e) => e.id === teamEventNoUserId.id); + expect(noUserIdResult).toBeDefined(); + expect(noUserIdResult?.team?.id).toBe(team3.id); + expect(noUserIdResult?.team?.name).toBe(team3.name); + }); + + it("should populate username for individual event types and null for team event types", async () => { + const result = await listWithTeamHandler({ + ctx: { user: { id: user1.id } }, + }); + + const individualResult = result.find((e) => e.id === eventType1.id); + expect(individualResult?.username).toBe(user1.username); + + const teamResult = result.find((e) => e.id === eventType2.id); + expect(teamResult?.username).toBeNull(); + + const noUserIdTeamResult = result.find((e) => e.id === teamEventNoUserId.id); + expect(noUserIdTeamResult?.username).toBeNull(); + }); + + it("should return username for user2 individual events", async () => { + const result = await listWithTeamHandler({ + ctx: { user: { id: user2.id } }, + }); + + const individualResult = result.find((e) => e.id === eventType3.id); + expect(individualResult?.username).toBe(user2.username); + + const teamEventResult = result.find((e) => e.id === eventType4.id); + expect(teamEventResult?.username).toBeNull(); + }); + + it("should return empty array for user with no event types", async () => { + const result = await listWithTeamHandler({ + ctx: { user: { id: user3.id } }, + }); + + expect(result).toHaveLength(0); + }); + + it("should not return team events for users with unaccepted membership", async () => { + const timestamp = Date.now(); + const pendingTeam = await prisma.team.create({ + data: { + name: "Pending Team lwt", + slug: `pending-team-lwt-${timestamp}`, + members: { + create: { + userId: user3.id, + role: MembershipRole.MEMBER, + accepted: false, + }, + }, + }, + }); + + const pendingTeamEvent = await prisma.eventType.create({ + data: { + title: "Pending Team Event", + slug: `pending-team-event-lwt-${timestamp}`, + length: 30, + teamId: pendingTeam.id, + }, + }); + + try { + const result = await listWithTeamHandler({ + ctx: { user: { id: user3.id } }, + }); + + const resultIds = result.map((e) => e.id); + expect(resultIds).not.toContain(pendingTeamEvent.id); + } finally { + await prisma.eventType.delete({ where: { id: pendingTeamEvent.id } }); + await prisma.team.delete({ where: { id: pendingTeam.id } }); + } }); }); diff --git a/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.ts index f7a1c8d61e..903852537a 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/listWithTeam.handler.ts @@ -1,6 +1,5 @@ import db from "@calcom/prisma"; import { Prisma } from "@calcom/prisma/client"; - import type { TrpcSessionUser } from "../../../types"; type ListWithTeamOptions = { @@ -15,25 +14,27 @@ export const listWithTeamHandler = async ({ ctx }: ListWithTeamOptions) => { FROM "public"."EventType" LEFT JOIN "public"."Team" AS "j1" ON ("j1"."id") = ("public"."EventType"."teamId") LEFT JOIN "public"."users" AS "u" ON ("u"."id") = ("public"."EventType"."userId") - WHERE "public"."EventType"."userId" = ${userId} AND "public"."EventType"."teamId" IS NULL + WHERE "public"."EventType"."userId" = ${userId} UNION - SELECT "public"."EventType"."id", "public"."EventType"."teamId", "public"."EventType"."title", "public"."EventType"."slug", "public"."EventType"."length", "j1"."name" as "teamName", NULL as "username" + SELECT "public"."EventType"."id", "public"."EventType"."teamId", "public"."EventType"."title", "public"."EventType"."slug", "public"."EventType"."length", "j1"."name" as "teamName", "u"."username" as "username" FROM "public"."EventType" INNER JOIN "public"."Team" AS "j1" ON ("j1"."id") = ("public"."EventType"."teamId") INNER JOIN "public"."Membership" AS "t2" ON "t2"."teamId" = "j1"."id" + LEFT JOIN "public"."users" AS "u" ON ("u"."id") = ("public"."EventType"."userId") WHERE "t2"."userId" = ${userId} AND "t2"."accepted" = true`; - const result = await db.$queryRaw< - { - id: number; - teamId: number | null; - title: string; - slug: string; - length: number; - teamName: string | null; - username: string | null; - }[] - >(query); + const result = + await db.$queryRaw< + { + id: number; + teamId: number | null; + title: string; + slug: string; + length: number; + teamName: string | null; + username: string | null; + }[] + >(query); return result.map((row) => ({ id: row.id,