Skip to content

Commit 43b6c63

Browse files
hbjORbjdevin-ai-integration[bot]eunjae-lee
authored
refactor: Move insights trpc router from features package to trpc layer (calcom#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 <sldisek783@gmail.com> * fix: update getEventTypeList import path after utils.ts deletion Co-Authored-By: benny@cal.com <sldisek783@gmail.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 <hey@eunjae.dev>
1 parent 9ef650c commit 43b6c63

8 files changed

Lines changed: 458 additions & 445 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { insightsRouter } from "@calcom/features/insights/server/trpc-router";
1+
import { insightsRouter } from "@calcom/trpc/server/routers/viewer/insights/_router";
22
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
33

44
export default createNextApiHandler(insightsRouter);
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { describe, expect, it, vi, beforeEach } from "vitest";
2+
3+
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
4+
import { readonlyPrisma } from "@calcom/prisma";
5+
6+
vi.mock("@calcom/prisma", () => ({
7+
readonlyPrisma: {
8+
eventType: {
9+
findMany: vi.fn(),
10+
},
11+
team: {
12+
findMany: vi.fn(),
13+
},
14+
membership: {
15+
findFirst: vi.fn(),
16+
},
17+
},
18+
}));
19+
20+
describe("EventTypeRepository", () => {
21+
let eventTypeRepository: EventTypeRepository;
22+
23+
beforeEach(() => {
24+
vi.resetAllMocks();
25+
eventTypeRepository = new EventTypeRepository(readonlyPrisma);
26+
});
27+
28+
const mockUser = {
29+
id: 1,
30+
organizationId: 10,
31+
isOwnerAdminOfParentTeam: false,
32+
};
33+
34+
const mockEventTypes = [
35+
{
36+
id: 1,
37+
slug: "personal-event",
38+
title: "Personal Event",
39+
teamId: null,
40+
userId: 1,
41+
team: null,
42+
},
43+
{
44+
id: 2,
45+
slug: "team-event",
46+
title: "Team Event",
47+
teamId: 5,
48+
userId: null,
49+
team: { name: "Team A" },
50+
},
51+
];
52+
53+
describe("getEventTypeList", () => {
54+
describe("Early return scenarios", () => {
55+
it("should return empty array when no teamId, userId, or isAll provided", async () => {
56+
const result = await eventTypeRepository.getEventTypeList({
57+
teamId: null,
58+
userId: null,
59+
isAll: false,
60+
user: mockUser,
61+
});
62+
63+
expect(result).toEqual([]);
64+
expect(readonlyPrisma.eventType.findMany).not.toHaveBeenCalled();
65+
});
66+
});
67+
68+
describe("Personal events filtering", () => {
69+
it("should return only user's personal events when userId provided", async () => {
70+
const personalEvents = [mockEventTypes[0]];
71+
vi.mocked(readonlyPrisma.eventType.findMany).mockResolvedValue(personalEvents);
72+
73+
const result = await eventTypeRepository.getEventTypeList({
74+
teamId: null,
75+
userId: 1,
76+
isAll: false,
77+
user: mockUser,
78+
});
79+
80+
expect(readonlyPrisma.eventType.findMany).toHaveBeenCalledWith({
81+
select: {
82+
id: true,
83+
slug: true,
84+
title: true,
85+
teamId: true,
86+
userId: true,
87+
team: {
88+
select: {
89+
name: true,
90+
},
91+
},
92+
},
93+
where: {
94+
userId: mockUser.id,
95+
teamId: null,
96+
},
97+
});
98+
expect(result).toEqual(personalEvents);
99+
});
100+
});
101+
102+
describe("Organization-wide view (isAll = true)", () => {
103+
it("should return team events and user's personal events for owner/admin", async () => {
104+
const childTeams = [{ id: 11 }, { id: 12 }];
105+
const allEvents = [...mockEventTypes];
106+
107+
vi.mocked(readonlyPrisma.team.findMany).mockResolvedValue(childTeams);
108+
vi.mocked(readonlyPrisma.eventType.findMany).mockResolvedValue(allEvents);
109+
110+
const ownerUser = { ...mockUser, isOwnerAdminOfParentTeam: true };
111+
112+
const result = await eventTypeRepository.getEventTypeList({
113+
teamId: null,
114+
userId: null,
115+
isAll: true,
116+
user: ownerUser,
117+
});
118+
119+
expect(readonlyPrisma.eventType.findMany).toHaveBeenCalledWith({
120+
select: {
121+
id: true,
122+
slug: true,
123+
title: true,
124+
teamId: true,
125+
userId: true,
126+
team: {
127+
select: {
128+
name: true,
129+
},
130+
},
131+
},
132+
where: {
133+
OR: [
134+
{
135+
teamId: {
136+
in: [10, 11, 12],
137+
},
138+
},
139+
{
140+
userId: ownerUser.id,
141+
teamId: null,
142+
},
143+
],
144+
},
145+
});
146+
expect(result).toEqual(allEvents);
147+
});
148+
});
149+
150+
describe("Team-specific view", () => {
151+
it("should return team events for team members", async () => {
152+
const membership = { teamId: 5, userId: 1, role: "MEMBER" };
153+
vi.mocked(readonlyPrisma.membership.findFirst).mockResolvedValue(membership);
154+
vi.mocked(readonlyPrisma.eventType.findMany).mockResolvedValue([mockEventTypes[1]]);
155+
156+
const result = await eventTypeRepository.getEventTypeList({
157+
teamId: 5,
158+
userId: null,
159+
isAll: false,
160+
user: mockUser,
161+
});
162+
163+
expect(readonlyPrisma.eventType.findMany).toHaveBeenCalledWith({
164+
select: {
165+
id: true,
166+
slug: true,
167+
title: true,
168+
teamId: true,
169+
userId: true,
170+
team: {
171+
select: {
172+
name: true,
173+
},
174+
},
175+
},
176+
where: {
177+
teamId: 5,
178+
OR: [{ userId: mockUser.id }, { users: { some: { id: mockUser.id } } }],
179+
},
180+
});
181+
expect(result).toEqual([mockEventTypes[1]]);
182+
});
183+
184+
it("should throw error when user is not part of team and not owner/admin", async () => {
185+
vi.mocked(readonlyPrisma.membership.findFirst).mockResolvedValue(null);
186+
187+
await expect(
188+
eventTypeRepository.getEventTypeList({
189+
teamId: 5,
190+
userId: null,
191+
isAll: false,
192+
user: mockUser,
193+
})
194+
).rejects.toThrow("User is not part of a team/org");
195+
});
196+
});
197+
});
198+
199+
// TODO: Add tests for other EventTypeRepository methods as they are added
200+
// Examples:
201+
// - describe("findById", () => { ... })
202+
// - describe("create", () => { ... })
203+
// - describe("findAllByUpId", () => { ... })
204+
// etc.
205+
});

packages/features/eventtypes/repositories/eventTypeRepository.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1693,4 +1693,145 @@ export class EventTypeRepository {
16931693
},
16941694
});
16951695
}
1696+
1697+
async getEventTypeList({
1698+
teamId,
1699+
userId,
1700+
isAll,
1701+
user,
1702+
}: {
1703+
teamId: number | null | undefined;
1704+
userId: number | null | undefined;
1705+
isAll: boolean | undefined;
1706+
user: {
1707+
id: number;
1708+
organizationId: number | null;
1709+
isOwnerAdminOfParentTeam: boolean;
1710+
};
1711+
}) {
1712+
if (!teamId && !userId && !isAll) {
1713+
return [];
1714+
}
1715+
1716+
const membershipWhereConditional: Prisma.MembershipWhereInput = {};
1717+
let childrenTeamIds: number[] = [];
1718+
1719+
if (userId && !teamId && !isAll) {
1720+
const eventTypeResult = await this.prismaClient.eventType.findMany({
1721+
select: {
1722+
id: true,
1723+
slug: true,
1724+
title: true,
1725+
teamId: true,
1726+
userId: true,
1727+
team: {
1728+
select: {
1729+
name: true,
1730+
},
1731+
},
1732+
},
1733+
where: {
1734+
userId: user.id,
1735+
teamId: null,
1736+
},
1737+
});
1738+
1739+
return eventTypeResult;
1740+
}
1741+
1742+
if (isAll && user.organizationId && user.isOwnerAdminOfParentTeam) {
1743+
const childTeams = await this.prismaClient.team.findMany({
1744+
where: {
1745+
parentId: user.organizationId,
1746+
},
1747+
select: {
1748+
id: true,
1749+
},
1750+
});
1751+
1752+
if (childTeams.length > 0) {
1753+
childrenTeamIds = childTeams.map((team) => team.id);
1754+
}
1755+
1756+
const eventTypeResult = await this.prismaClient.eventType.findMany({
1757+
select: {
1758+
id: true,
1759+
slug: true,
1760+
title: true,
1761+
teamId: true,
1762+
userId: true,
1763+
team: {
1764+
select: {
1765+
name: true,
1766+
},
1767+
},
1768+
},
1769+
where: {
1770+
OR: [
1771+
{
1772+
teamId: {
1773+
in: [user.organizationId, ...childrenTeamIds],
1774+
},
1775+
},
1776+
{
1777+
userId: user.id,
1778+
teamId: null,
1779+
},
1780+
],
1781+
},
1782+
});
1783+
1784+
return eventTypeResult;
1785+
}
1786+
1787+
if (teamId && !isAll) {
1788+
membershipWhereConditional["teamId"] = teamId;
1789+
membershipWhereConditional["userId"] = user.id;
1790+
}
1791+
1792+
// I'm not using unique here since when userId comes from input we should look for every
1793+
// event type that user owns
1794+
const membership = await this.prismaClient.membership.findFirst({
1795+
where: membershipWhereConditional,
1796+
});
1797+
1798+
if (!membership && !user.isOwnerAdminOfParentTeam) {
1799+
throw new Error("User is not part of a team/org");
1800+
}
1801+
1802+
const eventTypeWhereConditional: Prisma.EventTypeWhereInput = {};
1803+
1804+
if (teamId && !isAll) {
1805+
eventTypeWhereConditional["teamId"] = teamId;
1806+
}
1807+
1808+
let isMember = membership?.role === "MEMBER";
1809+
if (user.isOwnerAdminOfParentTeam) {
1810+
isMember = false;
1811+
}
1812+
1813+
if (isMember) {
1814+
eventTypeWhereConditional["OR"] = [{ userId: user.id }, { users: { some: { id: user.id } } }];
1815+
// @TODO this is not working as expected
1816+
// hosts: { some: { id: user.id } },
1817+
}
1818+
1819+
const eventTypeResult = await this.prismaClient.eventType.findMany({
1820+
select: {
1821+
id: true,
1822+
slug: true,
1823+
title: true,
1824+
teamId: true,
1825+
userId: true,
1826+
team: {
1827+
select: {
1828+
name: true,
1829+
},
1830+
},
1831+
},
1832+
where: eventTypeWhereConditional,
1833+
});
1834+
1835+
return eventTypeResult;
1836+
}
16961837
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export function objectToCsv(data: Record<string, string>[]) {
2+
if (!data.length) return "";
3+
4+
const headers = Object.keys(data[0]);
5+
const csvRows = [
6+
headers.join(","),
7+
...data.map((row) =>
8+
headers
9+
.map((header) => {
10+
const value = row[header]?.toString() || "";
11+
// Escape quotes and wrap in quotes if contains comma or newline
12+
return value.includes(",") || value.includes("\n") || value.includes('"')
13+
? `"${value.replace(/"/g, '""')}"` // escape double quotes
14+
: value;
15+
})
16+
.join(",")
17+
),
18+
];
19+
20+
return csvRows.join("\n");
21+
}

0 commit comments

Comments
 (0)