From 43b6c6329457c70580077831498481baefbf995a Mon Sep 17 00:00:00 2001 From: Benny Joo Date: Sun, 14 Dec 2025 20:44:36 +0900 Subject: [PATCH] refactor: Move insights trpc router from features package to trpc layer (#25778) * update imports * move trpc router from features to trpc * update imports * wip * update imports * wip * wip * wip * fix import * create objectToCsv.ts * rename * fix: restore proper types and static imports in insights handlers - Add proper type definitions for all handler options instead of using 'any' - Restore static imports for PermissionCheckService and MembershipRole - Remove dynamic imports that were introduced during refactoring This ensures the refactoring is pure with no behavioral changes. Co-Authored-By: benny@cal.com * fix: update getEventTypeList import path after utils.ts deletion Co-Authored-By: benny@cal.com * remove handlers * fix * refactor * wip * rm * wip * select only needed + fix type error --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Eunjae Lee --- apps/web/pages/api/trpc/insights/[trpc].ts | 2 +- .../__tests__/EventTypeRepository.test.ts | 205 ++++++++++++++ .../repositories/eventTypeRepository.ts | 141 ++++++++++ packages/features/insights/lib/objectToCsv.ts | 21 ++ .../server/__tests__/trpc-router.test.ts | 199 -------------- .../trpc/server/routers/viewer/_router.tsx | 2 +- .../routers/viewer/insights/_router.ts} | 257 +----------------- .../insights/procedures/userBelongsToTeam.ts | 76 ++++++ 8 files changed, 458 insertions(+), 445 deletions(-) create mode 100644 packages/features/eventtypes/repositories/__tests__/EventTypeRepository.test.ts create mode 100644 packages/features/insights/lib/objectToCsv.ts delete mode 100644 packages/features/insights/server/__tests__/trpc-router.test.ts rename packages/{features/insights/server/trpc-router.ts => trpc/server/routers/viewer/insights/_router.ts} (81%) create mode 100644 packages/trpc/server/routers/viewer/insights/procedures/userBelongsToTeam.ts diff --git a/apps/web/pages/api/trpc/insights/[trpc].ts b/apps/web/pages/api/trpc/insights/[trpc].ts index f0eb3490ac..b4325f0067 100644 --- a/apps/web/pages/api/trpc/insights/[trpc].ts +++ b/apps/web/pages/api/trpc/insights/[trpc].ts @@ -1,4 +1,4 @@ -import { insightsRouter } from "@calcom/features/insights/server/trpc-router"; +import { insightsRouter } from "@calcom/trpc/server/routers/viewer/insights/_router"; import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; export default createNextApiHandler(insightsRouter); diff --git a/packages/features/eventtypes/repositories/__tests__/EventTypeRepository.test.ts b/packages/features/eventtypes/repositories/__tests__/EventTypeRepository.test.ts new file mode 100644 index 0000000000..2469c46aab --- /dev/null +++ b/packages/features/eventtypes/repositories/__tests__/EventTypeRepository.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository"; +import { readonlyPrisma } from "@calcom/prisma"; + +vi.mock("@calcom/prisma", () => ({ + readonlyPrisma: { + eventType: { + findMany: vi.fn(), + }, + team: { + findMany: vi.fn(), + }, + membership: { + findFirst: vi.fn(), + }, + }, +})); + +describe("EventTypeRepository", () => { + let eventTypeRepository: EventTypeRepository; + + beforeEach(() => { + vi.resetAllMocks(); + eventTypeRepository = new EventTypeRepository(readonlyPrisma); + }); + + const mockUser = { + id: 1, + organizationId: 10, + isOwnerAdminOfParentTeam: false, + }; + + const mockEventTypes = [ + { + id: 1, + slug: "personal-event", + title: "Personal Event", + teamId: null, + userId: 1, + team: null, + }, + { + id: 2, + slug: "team-event", + title: "Team Event", + teamId: 5, + userId: null, + team: { name: "Team A" }, + }, + ]; + + describe("getEventTypeList", () => { + describe("Early return scenarios", () => { + it("should return empty array when no teamId, userId, or isAll provided", async () => { + const result = await eventTypeRepository.getEventTypeList({ + teamId: null, + userId: null, + isAll: false, + user: mockUser, + }); + + expect(result).toEqual([]); + expect(readonlyPrisma.eventType.findMany).not.toHaveBeenCalled(); + }); + }); + + describe("Personal events filtering", () => { + it("should return only user's personal events when userId provided", async () => { + const personalEvents = [mockEventTypes[0]]; + vi.mocked(readonlyPrisma.eventType.findMany).mockResolvedValue(personalEvents); + + const result = await eventTypeRepository.getEventTypeList({ + teamId: null, + userId: 1, + isAll: false, + user: mockUser, + }); + + expect(readonlyPrisma.eventType.findMany).toHaveBeenCalledWith({ + select: { + id: true, + slug: true, + title: true, + teamId: true, + userId: true, + team: { + select: { + name: true, + }, + }, + }, + where: { + userId: mockUser.id, + teamId: null, + }, + }); + expect(result).toEqual(personalEvents); + }); + }); + + describe("Organization-wide view (isAll = true)", () => { + it("should return team events and user's personal events for owner/admin", async () => { + const childTeams = [{ id: 11 }, { id: 12 }]; + const allEvents = [...mockEventTypes]; + + vi.mocked(readonlyPrisma.team.findMany).mockResolvedValue(childTeams); + vi.mocked(readonlyPrisma.eventType.findMany).mockResolvedValue(allEvents); + + const ownerUser = { ...mockUser, isOwnerAdminOfParentTeam: true }; + + const result = await eventTypeRepository.getEventTypeList({ + teamId: null, + userId: null, + isAll: true, + user: ownerUser, + }); + + expect(readonlyPrisma.eventType.findMany).toHaveBeenCalledWith({ + select: { + id: true, + slug: true, + title: true, + teamId: true, + userId: true, + team: { + select: { + name: true, + }, + }, + }, + where: { + OR: [ + { + teamId: { + in: [10, 11, 12], + }, + }, + { + userId: ownerUser.id, + teamId: null, + }, + ], + }, + }); + expect(result).toEqual(allEvents); + }); + }); + + describe("Team-specific view", () => { + it("should return team events for team members", async () => { + const membership = { teamId: 5, userId: 1, role: "MEMBER" }; + vi.mocked(readonlyPrisma.membership.findFirst).mockResolvedValue(membership); + vi.mocked(readonlyPrisma.eventType.findMany).mockResolvedValue([mockEventTypes[1]]); + + const result = await eventTypeRepository.getEventTypeList({ + teamId: 5, + userId: null, + isAll: false, + user: mockUser, + }); + + expect(readonlyPrisma.eventType.findMany).toHaveBeenCalledWith({ + select: { + id: true, + slug: true, + title: true, + teamId: true, + userId: true, + team: { + select: { + name: true, + }, + }, + }, + where: { + teamId: 5, + OR: [{ userId: mockUser.id }, { users: { some: { id: mockUser.id } } }], + }, + }); + expect(result).toEqual([mockEventTypes[1]]); + }); + + it("should throw error when user is not part of team and not owner/admin", async () => { + vi.mocked(readonlyPrisma.membership.findFirst).mockResolvedValue(null); + + await expect( + eventTypeRepository.getEventTypeList({ + teamId: 5, + userId: null, + isAll: false, + user: mockUser, + }) + ).rejects.toThrow("User is not part of a team/org"); + }); + }); + }); + + // TODO: Add tests for other EventTypeRepository methods as they are added + // Examples: + // - describe("findById", () => { ... }) + // - describe("create", () => { ... }) + // - describe("findAllByUpId", () => { ... }) + // etc. +}); diff --git a/packages/features/eventtypes/repositories/eventTypeRepository.ts b/packages/features/eventtypes/repositories/eventTypeRepository.ts index 86a8f8095d..0893adffb5 100644 --- a/packages/features/eventtypes/repositories/eventTypeRepository.ts +++ b/packages/features/eventtypes/repositories/eventTypeRepository.ts @@ -1693,4 +1693,145 @@ export class EventTypeRepository { }, }); } + + async getEventTypeList({ + teamId, + userId, + isAll, + user, + }: { + teamId: number | null | undefined; + userId: number | null | undefined; + isAll: boolean | undefined; + user: { + id: number; + organizationId: number | null; + isOwnerAdminOfParentTeam: boolean; + }; + }) { + if (!teamId && !userId && !isAll) { + return []; + } + + const membershipWhereConditional: Prisma.MembershipWhereInput = {}; + let childrenTeamIds: number[] = []; + + if (userId && !teamId && !isAll) { + const eventTypeResult = await this.prismaClient.eventType.findMany({ + select: { + id: true, + slug: true, + title: true, + teamId: true, + userId: true, + team: { + select: { + name: true, + }, + }, + }, + where: { + userId: user.id, + teamId: null, + }, + }); + + return eventTypeResult; + } + + if (isAll && user.organizationId && user.isOwnerAdminOfParentTeam) { + const childTeams = await this.prismaClient.team.findMany({ + where: { + parentId: user.organizationId, + }, + select: { + id: true, + }, + }); + + if (childTeams.length > 0) { + childrenTeamIds = childTeams.map((team) => team.id); + } + + const eventTypeResult = await this.prismaClient.eventType.findMany({ + select: { + id: true, + slug: true, + title: true, + teamId: true, + userId: true, + team: { + select: { + name: true, + }, + }, + }, + where: { + OR: [ + { + teamId: { + in: [user.organizationId, ...childrenTeamIds], + }, + }, + { + userId: user.id, + teamId: null, + }, + ], + }, + }); + + return eventTypeResult; + } + + if (teamId && !isAll) { + membershipWhereConditional["teamId"] = teamId; + membershipWhereConditional["userId"] = user.id; + } + + // I'm not using unique here since when userId comes from input we should look for every + // event type that user owns + const membership = await this.prismaClient.membership.findFirst({ + where: membershipWhereConditional, + }); + + if (!membership && !user.isOwnerAdminOfParentTeam) { + throw new Error("User is not part of a team/org"); + } + + const eventTypeWhereConditional: Prisma.EventTypeWhereInput = {}; + + if (teamId && !isAll) { + eventTypeWhereConditional["teamId"] = teamId; + } + + let isMember = membership?.role === "MEMBER"; + if (user.isOwnerAdminOfParentTeam) { + isMember = false; + } + + if (isMember) { + eventTypeWhereConditional["OR"] = [{ userId: user.id }, { users: { some: { id: user.id } } }]; + // @TODO this is not working as expected + // hosts: { some: { id: user.id } }, + } + + const eventTypeResult = await this.prismaClient.eventType.findMany({ + select: { + id: true, + slug: true, + title: true, + teamId: true, + userId: true, + team: { + select: { + name: true, + }, + }, + }, + where: eventTypeWhereConditional, + }); + + return eventTypeResult; + } } diff --git a/packages/features/insights/lib/objectToCsv.ts b/packages/features/insights/lib/objectToCsv.ts new file mode 100644 index 0000000000..b6dc8274b4 --- /dev/null +++ b/packages/features/insights/lib/objectToCsv.ts @@ -0,0 +1,21 @@ +export function objectToCsv(data: Record[]) { + if (!data.length) return ""; + + const headers = Object.keys(data[0]); + const csvRows = [ + headers.join(","), + ...data.map((row) => + headers + .map((header) => { + const value = row[header]?.toString() || ""; + // Escape quotes and wrap in quotes if contains comma or newline + return value.includes(",") || value.includes("\n") || value.includes('"') + ? `"${value.replace(/"/g, '""')}"` // escape double quotes + : value; + }) + .join(",") + ), + ]; + + return csvRows.join("\n"); +} diff --git a/packages/features/insights/server/__tests__/trpc-router.test.ts b/packages/features/insights/server/__tests__/trpc-router.test.ts deleted file mode 100644 index b01a4d92e6..0000000000 --- a/packages/features/insights/server/__tests__/trpc-router.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; - -import { readonlyPrisma } from "@calcom/prisma"; - -import { getEventTypeList } from "../trpc-router"; - -vi.mock("@calcom/prisma", () => ({ - readonlyPrisma: { - eventType: { - findMany: vi.fn(), - }, - team: { - findMany: vi.fn(), - }, - membership: { - findFirst: vi.fn(), - }, - }, -})); - -describe("getEventTypeList", () => { - beforeEach(() => { - vi.resetAllMocks(); - }); - - const mockUser = { - id: 1, - organizationId: 10, - isOwnerAdminOfParentTeam: false, - }; - - const mockEventTypes = [ - { - id: 1, - slug: "personal-event", - title: "Personal Event", - teamId: null, - userId: 1, - team: null, - }, - { - id: 2, - slug: "team-event", - title: "Team Event", - teamId: 5, - userId: null, - team: { name: "Team A" }, - }, - ]; - - describe("Early return scenarios", () => { - it("should return empty array when no teamId, userId, or isAll provided", async () => { - const result = await getEventTypeList({ - prisma: readonlyPrisma, - teamId: null, - userId: null, - isAll: false, - user: mockUser, - }); - - expect(result).toEqual([]); - expect(readonlyPrisma.eventType.findMany).not.toHaveBeenCalled(); - }); - }); - - describe("Personal events filtering", () => { - it("should return only user's personal events when userId provided", async () => { - const personalEvents = [mockEventTypes[0]]; - vi.mocked(readonlyPrisma.eventType.findMany).mockResolvedValue(personalEvents); - - const result = await getEventTypeList({ - prisma: readonlyPrisma, - teamId: null, - userId: 1, - isAll: false, - user: mockUser, - }); - - expect(readonlyPrisma.eventType.findMany).toHaveBeenCalledWith({ - select: { - id: true, - slug: true, - title: true, - teamId: true, - userId: true, - team: { - select: { - name: true, - }, - }, - }, - where: { - userId: mockUser.id, - teamId: null, - }, - }); - expect(result).toEqual(personalEvents); - }); - }); - - describe("Organization-wide view (isAll = true)", () => { - it("should return team events and user's personal events for owner/admin", async () => { - const childTeams = [{ id: 11 }, { id: 12 }]; - const allEvents = [...mockEventTypes]; - - vi.mocked(readonlyPrisma.team.findMany).mockResolvedValue(childTeams); - vi.mocked(readonlyPrisma.eventType.findMany).mockResolvedValue(allEvents); - - const ownerUser = { ...mockUser, isOwnerAdminOfParentTeam: true }; - - const result = await getEventTypeList({ - prisma: readonlyPrisma, - teamId: null, - userId: null, - isAll: true, - user: ownerUser, - }); - - expect(readonlyPrisma.eventType.findMany).toHaveBeenCalledWith({ - select: { - id: true, - slug: true, - title: true, - teamId: true, - userId: true, - team: { - select: { - name: true, - }, - }, - }, - where: { - OR: [ - { - teamId: { - in: [10, 11, 12], - }, - }, - { - userId: ownerUser.id, - teamId: null, - }, - ], - }, - }); - expect(result).toEqual(allEvents); - }); - }); - - describe("Team-specific view", () => { - it("should return team events for team members", async () => { - const membership = { teamId: 5, userId: 1, role: "MEMBER" }; - vi.mocked(readonlyPrisma.membership.findFirst).mockResolvedValue(membership); - vi.mocked(readonlyPrisma.eventType.findMany).mockResolvedValue([mockEventTypes[1]]); - - const result = await getEventTypeList({ - prisma: readonlyPrisma, - teamId: 5, - userId: null, - isAll: false, - user: mockUser, - }); - - expect(readonlyPrisma.eventType.findMany).toHaveBeenCalledWith({ - select: { - id: true, - slug: true, - title: true, - teamId: true, - userId: true, - team: { - select: { - name: true, - }, - }, - }, - where: { - teamId: 5, - OR: [{ userId: mockUser.id }, { users: { some: { id: mockUser.id } } }], - }, - }); - expect(result).toEqual([mockEventTypes[1]]); - }); - - it("should throw error when user is not part of team and not owner/admin", async () => { - vi.mocked(readonlyPrisma.membership.findFirst).mockResolvedValue(null); - - await expect( - getEventTypeList({ - prisma: readonlyPrisma, - teamId: 5, - userId: null, - isAll: false, - user: mockUser, - }) - ).rejects.toThrow("User is not part of a team/org"); - }); - }); -}); diff --git a/packages/trpc/server/routers/viewer/_router.tsx b/packages/trpc/server/routers/viewer/_router.tsx index 1ce6646198..28832bf5df 100644 --- a/packages/trpc/server/routers/viewer/_router.tsx +++ b/packages/trpc/server/routers/viewer/_router.tsx @@ -1,6 +1,5 @@ import { userAdminRouter } from "@calcom/features/ee/users/server/trpc-router"; import { featureFlagRouter } from "@calcom/features/flags/server/router"; -import { insightsRouter } from "@calcom/features/insights/server/trpc-router"; import { router } from "../../trpc"; import app_RoutingForms from "../apps/routing-forms/_router"; @@ -27,6 +26,7 @@ import { eventTypesRouter as heavyEventTypesRouter } from "./eventTypes/heavy/_r import { filterSegmentsRouter } from "./filterSegments/_router"; import { googleWorkspaceRouter } from "./googleWorkspace/_router"; import { i18nRouter } from "./i18n/_router"; +import { insightsRouter } from "./insights/_router"; import { meRouter } from "./me/_router"; import { oAuthRouter } from "./oAuth/_router"; import { oooRouter } from "./ooo/_router"; diff --git a/packages/features/insights/server/trpc-router.ts b/packages/trpc/server/routers/viewer/insights/_router.ts similarity index 81% rename from packages/features/insights/server/trpc-router.ts rename to packages/trpc/server/routers/viewer/insights/_router.ts index f84adfa671..96a1b95cf3 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/trpc/server/routers/viewer/insights/_router.ts @@ -3,92 +3,36 @@ import { z } from "zod"; import dayjs from "@calcom/dayjs"; import { getInsightsBookingService } from "@calcom/features/di/containers/InsightsBooking"; import { getInsightsRoutingService } from "@calcom/features/di/containers/InsightsRouting"; +import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository"; import { extractDateRangeFromColumnFilters, replaceDateRangeColumnFilter, } from "@calcom/features/insights/lib/bookingUtils"; +import { objectToCsv } from "@calcom/features/insights/lib/objectToCsv"; import { + getTimeView, + getDateRanges, + type GetDateRangesParams, +} from "@calcom/features/insights/server/insightsDateUtils"; +import { + bookingRepositoryBaseInputSchema, insightsRoutingServiceInputSchema, insightsRoutingServicePaginatedInputSchema, routingRepositoryBaseInputSchema, routedToPerPeriodInputSchema, routedToPerPeriodCsvInputSchema, - bookingRepositoryBaseInputSchema, } from "@calcom/features/insights/server/raw-data.schema"; +import { RoutingEventsInsights } from "@calcom/features/insights/server/routing-events"; +import { VirtualQueuesInsights } from "@calcom/features/insights/server/virtual-queues"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import type { PrismaClient } from "@calcom/prisma"; -import type { Prisma } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure"; import { router } from "@calcom/trpc/server/trpc"; import { TRPCError } from "@trpc/server"; -import { getTimeView, getDateRanges, type GetDateRangesParams } from "./insightsDateUtils"; -import { RoutingEventsInsights } from "./routing-events"; -import { VirtualQueuesInsights } from "./virtual-queues"; - -const UserBelongsToTeamInput = z.object({ - teamId: z.coerce.number().optional().nullable(), - isAll: z.boolean().optional(), -}); - -const userBelongsToTeamProcedure = authedProcedure.use(async ({ ctx, next, getRawInput }) => { - const parse = UserBelongsToTeamInput.safeParse(await getRawInput()); - if (!parse.success) { - throw new TRPCError({ code: "BAD_REQUEST" }); - } - - // If teamId is provided, check if user belongs to team - // If teamId is not provided, check if user belongs to any team - - const membershipWhereConditional: Prisma.MembershipWhereInput = { - userId: ctx.user.id, - accepted: true, - }; - - if (parse.data.teamId) { - membershipWhereConditional["teamId"] = parse.data.teamId; - } - - const membership = await ctx.insightsDb.membership.findFirst({ - where: membershipWhereConditional, - }); - - let isOwnerAdminOfParentTeam = false; - - // Probably we couldn't find a membership because the user is not a direct member of the team - // So that would mean ctx.user.organization is present - if ((parse.data.isAll && ctx.user.organizationId) || (!membership && ctx.user.organizationId)) { - //Look for membership type in organizationId - if (!membership && ctx.user.organizationId && parse.data.teamId) { - const isChildTeamOfOrg = await ctx.insightsDb.team.findFirst({ - where: { - id: parse.data.teamId, - parentId: ctx.user.organizationId, - }, - }); - if (!isChildTeamOfOrg) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - } - - const hasOrgAccess = await checkInsightsPermission(ctx.user.id, ctx.user.organizationId); - if (!hasOrgAccess) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - isOwnerAdminOfParentTeam = true; - } - - return next({ - ctx: { - user: { - ...ctx.user, - isOwnerAdminOfParentTeam, - }, - }, - }); -}); +import { userBelongsToTeamProcedure } from "./procedures/userBelongsToTeam"; const userSelect = { id: true, @@ -98,16 +42,6 @@ const userSelect = { avatarUrl: true, }; -async function checkInsightsPermission(userId: number, teamId: number): Promise { - const permissionCheckService = new PermissionCheckService(); - return await permissionCheckService.checkPermission({ - userId, - teamId, - permission: "insights.read", - fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN], - }); -} - const emptyResponseBookingKPIStats = { empty: true, created: { @@ -633,8 +567,8 @@ export const insightsRouter = router({ const { prisma, user } = ctx; const { teamId, userId, isAll } = input; - const eventTypeList = await getEventTypeList({ - prisma, + const eventTypeRepository = new EventTypeRepository(prisma); + const eventTypeList = await eventTypeRepository.getEventTypeList({ teamId, userId, isAll, @@ -944,168 +878,3 @@ export const insightsRouter = router({ } }), }); - -export async function getEventTypeList({ - prisma, - teamId, - userId, - isAll, - user, -}: { - prisma: PrismaClient; - teamId: number | null | undefined; - userId: number | null | undefined; - isAll: boolean | undefined; - user: { - id: number; - organizationId: number | null; - isOwnerAdminOfParentTeam: boolean; - }; -}) { - if (!teamId && !userId && !isAll) { - return []; - } - - const membershipWhereConditional: Prisma.MembershipWhereInput = {}; - let childrenTeamIds: number[] = []; - - if (userId && !teamId && !isAll) { - const eventTypeResult = await prisma.eventType.findMany({ - select: { - id: true, - slug: true, - title: true, - teamId: true, - userId: true, - team: { - select: { - name: true, - }, - }, - }, - where: { - userId: user.id, - teamId: null, - }, - }); - - return eventTypeResult; - } - - if (isAll && user.organizationId && user.isOwnerAdminOfParentTeam) { - const childTeams = await prisma.team.findMany({ - where: { - parentId: user.organizationId, - }, - select: { - id: true, - }, - }); - - if (childTeams.length > 0) { - childrenTeamIds = childTeams.map((team) => team.id); - } - - const eventTypeResult = await prisma.eventType.findMany({ - select: { - id: true, - slug: true, - title: true, - teamId: true, - userId: true, - team: { - select: { - name: true, - }, - }, - }, - where: { - OR: [ - { - teamId: { - in: [user.organizationId, ...childrenTeamIds], - }, - }, - { - userId: user.id, - teamId: null, - }, - ], - }, - }); - - return eventTypeResult; - } - - if (teamId && !isAll) { - membershipWhereConditional["teamId"] = teamId; - membershipWhereConditional["userId"] = user.id; - } - - // I'm not using unique here since when userId comes from input we should look for every - // event type that user owns - const membership = await prisma.membership.findFirst({ - where: membershipWhereConditional, - }); - - if (!membership && !user.isOwnerAdminOfParentTeam) { - throw new Error("User is not part of a team/org"); - } - - const eventTypeWhereConditional: Prisma.EventTypeWhereInput = {}; - - if (teamId && !isAll) { - eventTypeWhereConditional["teamId"] = teamId; - } - - let isMember = membership?.role === "MEMBER"; - if (user.isOwnerAdminOfParentTeam) { - isMember = false; - } - - if (isMember) { - eventTypeWhereConditional["OR"] = [{ userId: user.id }, { users: { some: { id: user.id } } }]; - // @TODO this is not working as expected - // hosts: { some: { id: user.id } }, - } - - const eventTypeResult = await prisma.eventType.findMany({ - select: { - id: true, - slug: true, - title: true, - teamId: true, - userId: true, - team: { - select: { - name: true, - }, - }, - }, - where: eventTypeWhereConditional, - }); - - return eventTypeResult; -} - -function objectToCsv(data: Record[]) { - if (!data.length) return ""; - - const headers = Object.keys(data[0]); - const csvRows = [ - headers.join(","), - ...data.map((row) => - headers - .map((header) => { - const value = row[header]?.toString() || ""; - // Escape quotes and wrap in quotes if contains comma or newline - return value.includes(",") || value.includes("\n") || value.includes('"') - ? `"${value.replace(/"/g, '""')}"` // escape double quotes - : value; - }) - .join(",") - ), - ]; - - return csvRows.join("\n"); -} diff --git a/packages/trpc/server/routers/viewer/insights/procedures/userBelongsToTeam.ts b/packages/trpc/server/routers/viewer/insights/procedures/userBelongsToTeam.ts new file mode 100644 index 0000000000..161fda16e9 --- /dev/null +++ b/packages/trpc/server/routers/viewer/insights/procedures/userBelongsToTeam.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; + +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +import { MembershipRole } from "@calcom/prisma/enums"; +import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure"; + +import { TRPCError } from "@trpc/server"; + +const UserBelongsToTeamInput = z.object({ + teamId: z.coerce.number().optional().nullable(), + isAll: z.boolean().optional(), +}); + +async function checkInsightsPermission(userId: number, teamId: number): Promise { + const permissionCheckService = new PermissionCheckService(); + return await permissionCheckService.checkPermission({ + userId, + teamId, + permission: "insights.read", + fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN], + }); +} + +export const userBelongsToTeamProcedure = authedProcedure.use(async ({ ctx, next, getRawInput }) => { + const parse = UserBelongsToTeamInput.safeParse(await getRawInput()); + if (!parse.success) { + throw new TRPCError({ code: "BAD_REQUEST" }); + } + + // If teamId is provided, check if user belongs to team + // If teamId is not provided, check if user belongs to any team + + const membership = await ctx.insightsDb.membership.findFirst({ + where: { + userId: ctx.user.id, + accepted: true, + ...(parse.data.teamId && { teamId: parse.data.teamId }), + }, + select: { id: true }, + }); + + let isOwnerAdminOfParentTeam = false; + + // Probably we couldn't find a membership because the user is not a direct member of the team + // So that would mean ctx.user.organization is present + if ((parse.data.isAll && ctx.user.organizationId) || (!membership && ctx.user.organizationId)) { + //Look for membership type in organizationId + if (!membership && ctx.user.organizationId && parse.data.teamId) { + const isChildTeamOfOrg = await ctx.insightsDb.team.findFirst({ + where: { + id: parse.data.teamId, + parentId: ctx.user.organizationId, + }, + select: { id: true }, + }); + if (!isChildTeamOfOrg) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + } + + const hasOrgAccess = await checkInsightsPermission(ctx.user.id, ctx.user.organizationId); + if (!hasOrgAccess) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + isOwnerAdminOfParentTeam = true; + } + + return next({ + ctx: { + user: { + ...ctx.user, + isOwnerAdminOfParentTeam, + }, + }, + }); +});