diff --git a/packages/trpc/server/routers/viewer/filterSegments/__tests__/create.handler.test.ts b/packages/lib/server/repository/__tests__/filterSegments/create.test.ts similarity index 84% rename from packages/trpc/server/routers/viewer/filterSegments/__tests__/create.handler.test.ts rename to packages/lib/server/repository/__tests__/filterSegments/create.test.ts index c87e236907..ff3d2b1236 100644 --- a/packages/trpc/server/routers/viewer/filterSegments/__tests__/create.handler.test.ts +++ b/packages/lib/server/repository/__tests__/filterSegments/create.test.ts @@ -1,4 +1,4 @@ -import prismock from "../../../../../../../tests/libs/__mocks__/prisma"; +import prismock from "../../../../../../tests/libs/__mocks__/prisma"; import { describe, expect, it } from "vitest"; @@ -6,12 +6,15 @@ import { ColumnFilterType } from "@calcom/features/data-table/lib/types"; import { MembershipRole } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; -import { createHandler } from "../create.handler"; -import { type TCreateFilterSegmentInputSchema } from "../create.schema"; +import { FilterSegmentRepository } from "../../filterSegment"; +import type { TCreateFilterSegmentInputSchema } from "../../filterSegment.type"; -describe("createHandler", () => { +const repository = new FilterSegmentRepository(); + +describe("FilterSegmentRepository.create()", () => { + const userId = 1; const mockUser = { - id: 1, + id: userId, name: "Test User", } as NonNullable; @@ -40,8 +43,8 @@ describe("createHandler", () => { name: "My Bookings", }; - const result = await createHandler({ - ctx: { user: mockUser }, + const result = await repository.create({ + userId, input, }); @@ -74,8 +77,8 @@ describe("createHandler", () => { name: "Team Bookings", }; - const result = await createHandler({ - ctx: { user: mockUser }, + const result = await repository.create({ + userId, input, }); @@ -109,8 +112,8 @@ describe("createHandler", () => { }; await expect( - createHandler({ - ctx: { user: mockUser }, + repository.create({ + userId, input, }) ).rejects.toThrow("You must be a team admin or owner to create team segments"); @@ -124,8 +127,8 @@ describe("createHandler", () => { }; await expect( - createHandler({ - ctx: { user: mockUser }, + repository.create({ + userId, // @ts-expect-error - Testing invalid input where teamId is missing for TEAM scope input: invalidInput, }) @@ -141,8 +144,8 @@ describe("createHandler", () => { }; await expect( - createHandler({ - ctx: { user: mockUser }, + repository.create({ + userId, // @ts-expect-error - Testing invalid input where teamId is present for USER scope input: invalidInput, }) diff --git a/packages/trpc/server/routers/viewer/filterSegments/__tests__/delete.handler.test.ts b/packages/lib/server/repository/__tests__/filterSegments/delete.test.ts similarity index 80% rename from packages/trpc/server/routers/viewer/filterSegments/__tests__/delete.handler.test.ts rename to packages/lib/server/repository/__tests__/filterSegments/delete.test.ts index 05c74c9b23..9e1100b4fd 100644 --- a/packages/trpc/server/routers/viewer/filterSegments/__tests__/delete.handler.test.ts +++ b/packages/lib/server/repository/__tests__/filterSegments/delete.test.ts @@ -1,16 +1,19 @@ -import prismock from "../../../../../../../tests/libs/__mocks__/prisma"; +import prismock from "../../../../../../tests/libs/__mocks__/prisma"; import { describe, expect, it } from "vitest"; import { MembershipRole } from "@calcom/prisma/enums"; -import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; +import type { TrpcSessionUser } from "@calcom/trpc/server/types"; -import { deleteHandler } from "../delete.handler"; -import { type TDeleteFilterSegmentInputSchema } from "../delete.schema"; +import { FilterSegmentRepository } from "../../filterSegment"; +import { type TDeleteFilterSegmentInputSchema } from "../../filterSegment.type"; -describe("deleteHandler", () => { +const repository = new FilterSegmentRepository(); + +describe("FilterSegmentRepository.delete()", () => { + const userId = 1; const mockUser = { - id: 1, + id: userId, name: "Test User", } as NonNullable; @@ -34,15 +37,12 @@ describe("deleteHandler", () => { id: segment.id, }; - const result = await deleteHandler({ - ctx: { user: mockUser }, - input, - }); - - expect(result).toEqual({ - id: segment.id, - message: "Filter segment deleted successfully", - }); + await expect( + repository.delete({ + userId, + id: segment.id, + }) + ).resolves.not.toThrow(); // Verify segment is deleted const deletedSegment = await prismock.filterSegment.findUnique({ @@ -90,15 +90,12 @@ describe("deleteHandler", () => { id: segment.id, }; - const result = await deleteHandler({ - ctx: { user: mockUser }, - input, - }); - - expect(result).toEqual({ - id: segment.id, - message: "Filter segment deleted successfully", - }); + await expect( + repository.delete({ + userId, + id: segment.id, + }) + ).resolves.not.toThrow(); // Verify segment is deleted const deletedSegment = await prismock.filterSegment.findUnique({ @@ -113,9 +110,9 @@ describe("deleteHandler", () => { }; await expect( - deleteHandler({ - ctx: { user: mockUser }, - input, + repository.delete({ + userId, + id: 999, }) ).rejects.toThrow("Filter segment not found or you don't have permission to delete it"); }); @@ -160,9 +157,9 @@ describe("deleteHandler", () => { }; await expect( - deleteHandler({ - ctx: { user: mockUser }, - input, + repository.delete({ + userId, + id: segment.id, }) ).rejects.toThrow("Filter segment not found or you don't have permission to delete it"); }); diff --git a/packages/trpc/server/routers/viewer/filterSegments/__tests__/list.handler.test.ts b/packages/lib/server/repository/__tests__/filterSegments/get.test.ts similarity index 68% rename from packages/trpc/server/routers/viewer/filterSegments/__tests__/list.handler.test.ts rename to packages/lib/server/repository/__tests__/filterSegments/get.test.ts index bd164376af..f8cea5d471 100644 --- a/packages/trpc/server/routers/viewer/filterSegments/__tests__/list.handler.test.ts +++ b/packages/lib/server/repository/__tests__/filterSegments/get.test.ts @@ -1,16 +1,19 @@ -import prismock from "../../../../../../../tests/libs/__mocks__/prisma"; +import prismock from "../../../../../../tests/libs/__mocks__/prisma"; import { describe, expect, it } from "vitest"; import { MembershipRole } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; -import { listHandler } from "../list.handler"; -import { type TListFilterSegmentsInputSchema } from "../list.schema"; +import { FilterSegmentRepository } from "../../filterSegment"; +import { type TListFilterSegmentsInputSchema } from "../../filterSegment.type"; -describe("listHandler", () => { +const repository = new FilterSegmentRepository(); + +describe("FilterSegmentRepository.get()", () => { + const userId = 1; const mockUser = { - id: 1, + id: userId, name: "Test User", } as NonNullable; @@ -48,9 +51,9 @@ describe("listHandler", () => { tableIdentifier: "bookings", }; - const result = await listHandler({ - ctx: { user: mockUser }, - input, + const result = await repository.get({ + userId, + tableIdentifier: input.tableIdentifier, }); expect(result.segments).toHaveLength(2); @@ -132,9 +135,9 @@ describe("listHandler", () => { tableIdentifier: "bookings", }; - const result = await listHandler({ - ctx: { user: mockUser }, - input, + const result = await repository.get({ + userId, + tableIdentifier: input.tableIdentifier, }); expect(result.segments).toHaveLength(2); @@ -216,9 +219,9 @@ describe("listHandler", () => { tableIdentifier: "bookings", }; - const result = await listHandler({ - ctx: { user: mockUser }, - input, + const result = await repository.get({ + userId, + tableIdentifier: input.tableIdentifier, }); expect(result.segments).toHaveLength(2); @@ -251,9 +254,9 @@ describe("listHandler", () => { tableIdentifier: "bookings", }; - const result = await listHandler({ - ctx: { user: mockUser }, - input, + const result = await repository.get({ + userId, + tableIdentifier: input.tableIdentifier, }); expect(result).toEqual({ @@ -261,4 +264,74 @@ describe("listHandler", () => { preferredSegmentId: null, }); }); + + it("should return segments with user preferences", async () => { + // Create a filter segment + const segment = await prismock.filterSegment.create({ + data: { + userId: mockUser.id, + scope: "USER", + tableIdentifier: "contacts", + name: "Important Contacts", + activeFilters: [{ f: "priority" }], + sorting: [{ id: "lastContact", desc: true }], + columnVisibility: {}, + columnSizing: {}, + perPage: 50, + }, + }); + + // Create a user preference for this segment + await prismock.userFilterSegmentPreference.create({ + data: { + userId: mockUser.id, + tableIdentifier: "contacts", + segmentId: segment.id, + }, + }); + + const result = await repository.get({ + userId: mockUser.id, + tableIdentifier: "contacts", + }); + + expect(result.segments).toHaveLength(1); + expect(result.preferredSegmentId).toBe(segment.id); + expect(result.segments[0].name).toBe("Important Contacts"); + }); + + it("should handle segments with null JSON fields gracefully", async () => { + // Create segment with minimal data (some fields will be null/undefined) + await prismock.filterSegment.create({ + data: { + userId: mockUser.id, + scope: "USER", + tableIdentifier: "reports", + name: "Basic Report", + activeFilters: undefined, // This should be parsed to [] + sorting: undefined, // This should be parsed to [] + columnVisibility: undefined, // This should be parsed to {} + columnSizing: undefined, // This should be parsed to {} + perPage: 10, + searchTerm: null, + }, + }); + + const result = await repository.get({ + userId: mockUser.id, + tableIdentifier: "reports", + }); + + expect(result.segments).toHaveLength(1); + expect(result.segments[0]).toEqual( + expect.objectContaining({ + name: "Basic Report", + activeFilters: [], // Should default to empty array + sorting: [], // Should default to empty array + columnVisibility: {}, // Should default to empty object + columnSizing: {}, // Should default to empty object + perPage: 10, + }) + ); + }); }); diff --git a/packages/trpc/server/routers/viewer/filterSegments/__tests__/update.handler.test.ts b/packages/lib/server/repository/__tests__/filterSegments/update.test.ts similarity index 90% rename from packages/trpc/server/routers/viewer/filterSegments/__tests__/update.handler.test.ts rename to packages/lib/server/repository/__tests__/filterSegments/update.test.ts index 73f45aae7c..2b676b5a17 100644 --- a/packages/trpc/server/routers/viewer/filterSegments/__tests__/update.handler.test.ts +++ b/packages/lib/server/repository/__tests__/filterSegments/update.test.ts @@ -1,4 +1,4 @@ -import prismock from "../../../../../../../tests/libs/__mocks__/prisma"; +import prismock from "../../../../../../tests/libs/__mocks__/prisma"; import { describe, expect, it } from "vitest"; @@ -6,12 +6,15 @@ import { ColumnFilterType } from "@calcom/features/data-table/lib/types"; import { MembershipRole } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; -import { updateHandler } from "../update.handler"; -import { type TUpdateFilterSegmentInputSchema } from "../update.schema"; +import { FilterSegmentRepository } from "../../filterSegment"; +import { type TUpdateFilterSegmentInputSchema } from "../../filterSegment.type"; -describe("updateHandler", () => { +const repository = new FilterSegmentRepository(); + +describe("FilterSegmentRepository.update()", () => { + const userId = 1; const mockUser = { - id: 1, + id: userId, name: "Test User", } as NonNullable; @@ -61,8 +64,8 @@ describe("updateHandler", () => { ], }; - const result = await updateHandler({ - ctx: { user: mockUser }, + const result = await repository.update({ + userId, input, }); @@ -130,8 +133,8 @@ describe("updateHandler", () => { ], }; - const result = await updateHandler({ - ctx: { user: mockUser }, + const result = await repository.update({ + userId, input, }); @@ -171,8 +174,8 @@ describe("updateHandler", () => { }; await expect( - updateHandler({ - ctx: { user: mockUser }, + repository.update({ + userId, input, }) ).rejects.toThrow("Filter segment not found or you don't have permission to update it"); @@ -236,8 +239,8 @@ describe("updateHandler", () => { }; await expect( - updateHandler({ - ctx: { user: mockUser }, + repository.update({ + userId, input, }) ).rejects.toThrow("Filter segment not found or you don't have permission to update it"); diff --git a/packages/lib/server/repository/filterSegment.ts b/packages/lib/server/repository/filterSegment.ts index f5e0666c47..0dc0182bd8 100644 --- a/packages/lib/server/repository/filterSegment.ts +++ b/packages/lib/server/repository/filterSegment.ts @@ -1,7 +1,281 @@ +import type { FilterSegmentOutput } from "@calcom/features/data-table/lib/types"; +import { + ZActiveFilters, + ZSortingState, + ZColumnSizing, + ZColumnVisibility, +} from "@calcom/features/data-table/lib/types"; import { prisma } from "@calcom/prisma"; +import type { FilterSegment, UserFilterSegmentPreference } from "@calcom/prisma/client"; -export class FilterSegmentRepository { - static async setPreference({ +import type { TCreateFilterSegmentInputSchema, TUpdateFilterSegmentInputSchema } from "./filterSegment.type"; + +export interface IFilterSegmentRepository { + get({ userId, tableIdentifier }: { userId: number; tableIdentifier: string }): Promise<{ + segments: FilterSegmentOutput[]; + preferredSegmentId: number | null; + }>; + + create({ + userId, + input, + }: { + userId: number; + input: TCreateFilterSegmentInputSchema; + }): Promise; + + update({ + userId, + input, + }: { + userId: number; + input: TUpdateFilterSegmentInputSchema; + }): Promise; + + delete({ userId, id }: { userId: number; id: number }): Promise; + + setPreference({ + userId, + tableIdentifier, + segmentId, + }: { + userId: number; + tableIdentifier: string; + segmentId: number | null; + }): Promise; +} + +export class FilterSegmentRepository implements IFilterSegmentRepository { + async get({ userId, tableIdentifier }: { userId: number; tableIdentifier: string }) { + // Get all teams that the user is a member of + const userTeamIds = await prisma.membership + .findMany({ + where: { + userId, + accepted: true, + }, + select: { + teamId: true, + }, + }) + .then((memberships) => memberships.map((m) => m.teamId)); + + // Fetch both user-scoped and team-scoped segments + const segments = await prisma.filterSegment.findMany({ + where: { + tableIdentifier, + OR: [ + // User-scoped segments created by the current user + { + scope: "USER", + userId, + }, + // Team-scoped segments for teams the user belongs to + { + scope: "TEAM", + teamId: { + in: userTeamIds, + }, + }, + ], + }, + select: { + id: true, + name: true, + tableIdentifier: true, + scope: true, + activeFilters: true, + sorting: true, + columnVisibility: true, + columnSizing: true, + perPage: true, + searchTerm: true, + createdAt: true, + updatedAt: true, + userId: true, + teamId: true, + team: { + select: { + id: true, + name: true, + }, + }, + }, + orderBy: [ + { scope: "desc" }, // USER segments first, then TEAM segments + { createdAt: "desc" }, // Newest first within each scope + ], + }); + + const parsedSegments: FilterSegmentOutput[] = segments.map((segment) => ({ + ...segment, + activeFilters: ZActiveFilters.catch([]).parse(segment.activeFilters), + sorting: ZSortingState.catch([]).parse(segment.sorting), + columnVisibility: ZColumnVisibility.catch({}).parse(segment.columnVisibility), + columnSizing: ZColumnSizing.catch({}).parse(segment.columnSizing), + })); + + const preference = await prisma.userFilterSegmentPreference.findUnique({ + where: { + userId_tableIdentifier: { + userId, + tableIdentifier, + }, + }, + select: { + segmentId: true, + }, + }); + + return { + segments: parsedSegments, + preferredSegmentId: preference?.segmentId || null, + }; + } + + async create({ userId, input }: { userId: number; input: TCreateFilterSegmentInputSchema }) { + const { scope, teamId, ...filterData } = input; + + // If scope is TEAM, verify user has admin/owner permissions + if (scope === "TEAM") { + if (!teamId) { + throw new Error("Team ID is required for team scope"); + } + + const membership = await prisma.membership.findFirst({ + select: { + id: true, + }, + where: { + userId, + teamId, + accepted: true, + role: { + in: ["ADMIN", "OWNER"], + }, + }, + }); + + if (!membership) { + throw new Error("You must be a team admin or owner to create team segments"); + } + } + + // For USER scope, ensure no teamId is provided + if (scope === "USER" && teamId) { + throw new Error("Team ID is not allowed for user scope"); + } + + // Create the filter segment + const filterSegment = await prisma.filterSegment.create({ + data: { + ...filterData, + scope, + ...(scope === "TEAM" ? { teamId } : {}), + userId, + }, + }); + + return filterSegment; + } + + async update({ userId, input }: { userId: number; input: TUpdateFilterSegmentInputSchema }) { + const { id, name, activeFilters, sorting, columnVisibility, columnSizing, perPage, searchTerm } = input; + + // First, fetch the existing segment to check permissions + const existingSegment = await prisma.filterSegment.findFirst({ + where: { + id, + ...(input.scope === "TEAM" + ? { + scope: "TEAM", + teamId: input.teamId, + team: { + members: { + some: { + userId, + accepted: true, + role: { + in: ["ADMIN", "OWNER"], + }, + }, + }, + }, + } + : { + scope: "USER", + userId, + }), + }, + select: { + id: true, + }, + }); + + if (!existingSegment) { + throw new Error("Filter segment not found or you don't have permission to update it"); + } + + // Update the filter segment with only the allowed fields + const updatedSegment = await prisma.filterSegment.update({ + where: { id }, + data: { + name, + activeFilters, + sorting, + columnVisibility, + columnSizing, + perPage, + searchTerm, + }, + }); + + return updatedSegment; + } + + async delete({ userId, id }: { userId: number; id: number }) { + // First, fetch the existing segment to check permissions + const existingSegment = await prisma.filterSegment.findFirst({ + where: { + id, + OR: [ + { + scope: "TEAM", + teamId: { not: null }, + team: { + members: { + some: { + userId, + accepted: true, + role: { + in: ["ADMIN", "OWNER"], + }, + }, + }, + }, + }, + { + scope: "USER", + userId, + }, + ], + }, + select: { + id: true, + }, + }); + + if (!existingSegment) { + throw new Error("Filter segment not found or you don't have permission to delete it"); + } + + // Delete the filter segment + await prisma.filterSegment.delete({ + where: { id }, + }); + } + + async setPreference({ userId, tableIdentifier, segmentId, diff --git a/packages/lib/server/repository/filterSegment.type.ts b/packages/lib/server/repository/filterSegment.type.ts new file mode 100644 index 0000000000..56850b69f5 --- /dev/null +++ b/packages/lib/server/repository/filterSegment.type.ts @@ -0,0 +1,100 @@ +import { z } from "zod"; + +import type { FilterSegmentOutput } from "@calcom/features/data-table/lib/types"; +import { + ZActiveFilters, + ZSortingState, + ZColumnSizing, + ZColumnVisibility, +} from "@calcom/features/data-table/lib/types"; + +export type FilterSegmentsListResponse = { + segments: FilterSegmentOutput[]; + preferredSegmentId: number | null; +}; + +export const ZListFilterSegmentsInputSchema = z.object({ + tableIdentifier: z.string(), +}); + +export type TListFilterSegmentsInputSchema = z.infer; + +export const ZDeleteFilterSegmentInputSchema = z.object({ + id: z.number().int().positive(), +}); + +export type TDeleteFilterSegmentInputSchema = z.infer; + +// Base schema for common fields +const baseCreateSchema = { + name: z.string().min(1), + tableIdentifier: z.string().min(1), + activeFilters: ZActiveFilters.optional(), + sorting: ZSortingState.optional(), + columnVisibility: ZColumnVisibility.optional(), + columnSizing: ZColumnSizing.optional(), + searchTerm: z.string().nullable().optional(), + perPage: z.number().int().min(1), +}; + +// Schema for team scope - requires teamId +const teamCreateSchema = z.object({ + scope: z.literal("TEAM"), + teamId: z.number().int().positive(), + ...baseCreateSchema, +}); + +// Schema for user scope - no teamId allowed +const userCreateSchema = z.object({ + scope: z.literal("USER"), + teamId: z.undefined().optional(), + ...baseCreateSchema, +}); + +export const ZCreateFilterSegmentInputSchema = z.discriminatedUnion("scope", [ + teamCreateSchema, + userCreateSchema, +]); + +export type TCreateFilterSegmentInputSchema = z.infer; + +export const ZSetFilterSegmentPreferenceInputSchema = z.object({ + tableIdentifier: z.string(), + segmentId: z.number().nullable(), +}); + +export type TSetFilterSegmentPreferenceInputSchema = z.infer; + +// Base schema for common fields that can be updated +const baseUpdateSchema = { + name: z.string().min(1).optional(), + tableIdentifier: z.string().min(1).optional(), + activeFilters: ZActiveFilters.optional(), + sorting: ZSortingState.optional(), + columnVisibility: ZColumnVisibility.optional(), + columnSizing: ZColumnSizing.optional(), + searchTerm: z.string().nullable().optional(), + perPage: z.number().int().min(1).optional(), +}; + +// Schema for team scope updates +const teamUpdateSchema = z.object({ + id: z.number().int().positive(), + scope: z.literal("TEAM"), + teamId: z.number().int().positive(), + ...baseUpdateSchema, +}); + +// Schema for user scope updates +const userUpdateSchema = z.object({ + id: z.number().int().positive(), + scope: z.literal("USER"), + ...baseUpdateSchema, +}); + +export const ZUpdateFilterSegmentInputSchema = z.discriminatedUnion("scope", [ + teamUpdateSchema, + userUpdateSchema, +]); + +export type TUpdateFilterSegmentInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/filterSegments/_router.tsx b/packages/trpc/server/routers/viewer/filterSegments/_router.tsx index 40e4070319..8d5a4fdb0a 100644 --- a/packages/trpc/server/routers/viewer/filterSegments/_router.tsx +++ b/packages/trpc/server/routers/viewer/filterSegments/_router.tsx @@ -1,17 +1,13 @@ +import { + ZCreateFilterSegmentInputSchema, + ZDeleteFilterSegmentInputSchema, + ZListFilterSegmentsInputSchema, + ZSetFilterSegmentPreferenceInputSchema, + ZUpdateFilterSegmentInputSchema, +} from "@calcom/lib/server/repository/filterSegment.type"; + import authedProcedure from "../../../procedures/authedProcedure"; import { router } from "../../../trpc"; -import { ZCreateFilterSegmentInputSchema } from "./create.schema"; -import { ZDeleteFilterSegmentInputSchema } from "./delete.schema"; -import { ZListFilterSegmentsInputSchema } from "./list.schema"; -import { ZSetFilterSegmentPreferenceInputSchema } from "./preference.schema"; -import { ZUpdateFilterSegmentInputSchema } from "./update.schema"; - -type FilterSegmentsRouterHandlerCache = { - list?: typeof import("./list.handler").listHandler; - create?: typeof import("./create.handler").createHandler; - update?: typeof import("./update.handler").updateHandler; - delete?: typeof import("./delete.handler").deleteHandler; -}; export const filterSegmentsRouter = router({ list: authedProcedure.input(ZListFilterSegmentsInputSchema).query(async ({ input, ctx }) => { @@ -24,27 +20,27 @@ export const filterSegmentsRouter = router({ }), create: authedProcedure.input(ZCreateFilterSegmentInputSchema).mutation(async ({ input, ctx }) => { - const { createHandler } = await import("./create.handler"); + const { createFilterSegmentHandler } = await import("./create.handler"); - return createHandler({ + return createFilterSegmentHandler({ ctx, input, }); }), update: authedProcedure.input(ZUpdateFilterSegmentInputSchema).mutation(async ({ input, ctx }) => { - const { updateHandler } = await import("./update.handler"); + const { updateFilterSegmentHandler } = await import("./update.handler"); - return updateHandler({ + return updateFilterSegmentHandler({ ctx, input, }); }), delete: authedProcedure.input(ZDeleteFilterSegmentInputSchema).mutation(async ({ input, ctx }) => { - const { deleteHandler } = await import("./delete.handler"); + const { deleteFilterSegmentHandler } = await import("./delete.handler"); - return deleteHandler({ + return deleteFilterSegmentHandler({ ctx, input, }); @@ -53,9 +49,9 @@ export const filterSegmentsRouter = router({ setPreference: authedProcedure .input(ZSetFilterSegmentPreferenceInputSchema) .mutation(async ({ input, ctx }) => { - const { setPreferenceHandler } = await import("./preference.handler"); + const { setFilterSegmentPreferenceHandler } = await import("./preference.handler"); - return setPreferenceHandler({ + return setFilterSegmentPreferenceHandler({ ctx, input, }); diff --git a/packages/trpc/server/routers/viewer/filterSegments/create.handler.ts b/packages/trpc/server/routers/viewer/filterSegments/create.handler.ts index 353eb22180..2fe0fda168 100644 --- a/packages/trpc/server/routers/viewer/filterSegments/create.handler.ts +++ b/packages/trpc/server/routers/viewer/filterSegments/create.handler.ts @@ -1,11 +1,8 @@ -import { prisma } from "@calcom/prisma"; +import { FilterSegmentRepository } from "@calcom/lib/server/repository/filterSegment"; +import type { TCreateFilterSegmentInputSchema } from "@calcom/lib/server/repository/filterSegment.type"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; -import { TRPCError } from "@trpc/server"; - -import type { TCreateFilterSegmentInputSchema } from "./create.schema"; - -export const createHandler = async ({ +export const createFilterSegmentHandler = async ({ ctx, input, }: { @@ -14,54 +11,9 @@ export const createHandler = async ({ }; input: TCreateFilterSegmentInputSchema; }) => { - const { scope, teamId, ...filterData } = input; - const userId = ctx.user.id; - - // If scope is TEAM, verify user has admin/owner permissions - if (scope === "TEAM") { - if (!teamId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Team ID is required for team scope", - }); - } - - const membership = await prisma.membership.findFirst({ - where: { - userId, - teamId, - accepted: true, - role: { - in: ["ADMIN", "OWNER"], - }, - }, - }); - - if (!membership) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You must be a team admin or owner to create team segments", - }); - } - } - - // For USER scope, ensure no teamId is provided - if (scope === "USER" && teamId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Team ID is not allowed for user scope", - }); - } - - // Create the filter segment - const filterSegment = await prisma.filterSegment.create({ - data: { - ...filterData, - scope, - ...(scope === "TEAM" ? { teamId } : {}), - userId, - }, + const repository = new FilterSegmentRepository(); + return await repository.create({ + userId: ctx.user.id, + input, }); - - return filterSegment; }; diff --git a/packages/trpc/server/routers/viewer/filterSegments/create.schema.ts b/packages/trpc/server/routers/viewer/filterSegments/create.schema.ts deleted file mode 100644 index 447230f194..0000000000 --- a/packages/trpc/server/routers/viewer/filterSegments/create.schema.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { z } from "zod"; - -import { ZActiveFilters, ZColumnSizing, ZColumnVisibility, ZSortingState } from "./types"; - -// Base schema for common fields -const baseCreateSchema = { - name: z.string().min(1), - tableIdentifier: z.string().min(1), - activeFilters: ZActiveFilters.optional(), - sorting: ZSortingState.optional(), - columnVisibility: ZColumnVisibility.optional(), - columnSizing: ZColumnSizing.optional(), - searchTerm: z.string().nullable().optional(), - perPage: z.number().int().min(1), -}; - -// Schema for team scope - requires teamId -const teamCreateSchema = z.object({ - scope: z.literal("TEAM"), - teamId: z.number().int().positive(), - ...baseCreateSchema, -}); - -// Schema for user scope - no teamId allowed -const userCreateSchema = z.object({ - scope: z.literal("USER"), - teamId: z.undefined(), - ...baseCreateSchema, -}); - -export const ZCreateFilterSegmentInputSchema = z.discriminatedUnion("scope", [ - teamCreateSchema, - userCreateSchema, -]); - -export type TCreateFilterSegmentInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/filterSegments/delete.handler.ts b/packages/trpc/server/routers/viewer/filterSegments/delete.handler.ts index f91b85cf26..e0bfa97ca9 100644 --- a/packages/trpc/server/routers/viewer/filterSegments/delete.handler.ts +++ b/packages/trpc/server/routers/viewer/filterSegments/delete.handler.ts @@ -1,11 +1,8 @@ -import { prisma } from "@calcom/prisma"; +import { FilterSegmentRepository } from "@calcom/lib/server/repository/filterSegment"; +import type { TDeleteFilterSegmentInputSchema } from "@calcom/lib/server/repository/filterSegment.type"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; -import { TRPCError } from "@trpc/server"; - -import type { TDeleteFilterSegmentInputSchema } from "./delete.schema"; - -export const deleteHandler = async ({ +export const deleteFilterSegmentHandler = async ({ ctx, input, }: { @@ -14,54 +11,14 @@ export const deleteHandler = async ({ }; input: TDeleteFilterSegmentInputSchema; }) => { - const { id } = input; - const userId = ctx.user.id; - - // First, fetch the existing segment to check permissions - const existingSegment = await prisma.filterSegment.findFirst({ - where: { - id, - OR: [ - { - scope: "TEAM", - teamId: { not: null }, - team: { - members: { - some: { - userId, - accepted: true, - role: { - in: ["ADMIN", "OWNER"], - }, - }, - }, - }, - }, - { - scope: "USER", - userId, - }, - ], - }, - select: { - id: true, - }, - }); - - if (!existingSegment) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Filter segment not found or you don't have permission to delete it", - }); - } - - // Delete the filter segment - await prisma.filterSegment.delete({ - where: { id }, + const repository = new FilterSegmentRepository(); + await repository.delete({ + userId: ctx.user.id, + id: input.id, }); return { - id, + id: input.id, message: "Filter segment deleted successfully", }; }; diff --git a/packages/trpc/server/routers/viewer/filterSegments/delete.schema.ts b/packages/trpc/server/routers/viewer/filterSegments/delete.schema.ts deleted file mode 100644 index d1c2faefb1..0000000000 --- a/packages/trpc/server/routers/viewer/filterSegments/delete.schema.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from "zod"; - -export const ZDeleteFilterSegmentInputSchema = z.object({ - id: z.number().int().positive(), -}); - -export type TDeleteFilterSegmentInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/filterSegments/list.handler.ts b/packages/trpc/server/routers/viewer/filterSegments/list.handler.ts index d0829bcc54..a70aa1af2a 100644 --- a/packages/trpc/server/routers/viewer/filterSegments/list.handler.ts +++ b/packages/trpc/server/routers/viewer/filterSegments/list.handler.ts @@ -1,15 +1,7 @@ -import { prisma } from "@calcom/prisma"; +import { FilterSegmentRepository } from "@calcom/lib/server/repository/filterSegment"; +import type { TListFilterSegmentsInputSchema } from "@calcom/lib/server/repository/filterSegment.type"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; -import type { TListFilterSegmentsInputSchema } from "./list.schema"; -import { - ZActiveFilters, - ZSortingState, - ZColumnSizing, - ZColumnVisibility, - type FilterSegmentOutput, -} from "./types"; - export const listHandler = async ({ ctx, input, @@ -19,77 +11,9 @@ export const listHandler = async ({ }; input: TListFilterSegmentsInputSchema; }) => { - const { tableIdentifier } = input; - const userId = ctx.user.id; - - // Get all teams that the user is a member of - const userTeamIds = await prisma.membership - .findMany({ - where: { - userId, - accepted: true, - }, - select: { - teamId: true, - }, - }) - .then((memberships) => memberships.map((m) => m.teamId)); - - // Fetch both user-scoped and team-scoped segments - const segments = await prisma.filterSegment.findMany({ - where: { - tableIdentifier, - OR: [ - // User-scoped segments created by the current user - { - scope: "USER", - userId, - }, - // Team-scoped segments for teams the user belongs to - { - scope: "TEAM", - teamId: { - in: userTeamIds, - }, - }, - ], - }, - include: { - team: { - select: { - id: true, - name: true, - }, - }, - }, - orderBy: [ - { scope: "desc" }, // USER segments first, then TEAM segments - { createdAt: "desc" }, // Newest first within each scope - ], + const repository = new FilterSegmentRepository(); + return await repository.get({ + userId: ctx.user.id, + tableIdentifier: input.tableIdentifier, }); - - const parsedSegments: FilterSegmentOutput[] = segments.map((segment) => ({ - ...segment, - activeFilters: ZActiveFilters.catch([]).parse(segment.activeFilters), - sorting: ZSortingState.catch([]).parse(segment.sorting), - columnVisibility: ZColumnVisibility.catch({}).parse(segment.columnVisibility), - columnSizing: ZColumnSizing.catch({}).parse(segment.columnSizing), - })); - - const preference = await prisma.userFilterSegmentPreference.findUnique({ - where: { - userId_tableIdentifier: { - userId, - tableIdentifier, - }, - }, - select: { - segmentId: true, - }, - }); - - return { - segments: parsedSegments, - preferredSegmentId: preference?.segmentId || null, - }; }; diff --git a/packages/trpc/server/routers/viewer/filterSegments/list.schema.ts b/packages/trpc/server/routers/viewer/filterSegments/list.schema.ts deleted file mode 100644 index a3068a4f88..0000000000 --- a/packages/trpc/server/routers/viewer/filterSegments/list.schema.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from "zod"; - -export const ZListFilterSegmentsInputSchema = z.object({ - tableIdentifier: z.string(), -}); - -export type TListFilterSegmentsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/filterSegments/preference.handler.ts b/packages/trpc/server/routers/viewer/filterSegments/preference.handler.ts index aa05986f52..5da5763d2c 100644 --- a/packages/trpc/server/routers/viewer/filterSegments/preference.handler.ts +++ b/packages/trpc/server/routers/viewer/filterSegments/preference.handler.ts @@ -1,9 +1,8 @@ import { FilterSegmentRepository } from "@calcom/lib/server/repository/filterSegment"; +import type { TSetFilterSegmentPreferenceInputSchema } from "@calcom/lib/server/repository/filterSegment.type"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; -import type { TSetFilterSegmentPreferenceInputSchema } from "./preference.schema"; - -export const setPreferenceHandler = async ({ +export const setFilterSegmentPreferenceHandler = async ({ ctx, input, }: { @@ -12,12 +11,10 @@ export const setPreferenceHandler = async ({ }; input: TSetFilterSegmentPreferenceInputSchema; }) => { - const { tableIdentifier, segmentId } = input; - const userId = ctx.user.id; - - return await FilterSegmentRepository.setPreference({ - userId, - tableIdentifier, - segmentId, + const repository = new FilterSegmentRepository(); + return await repository.setPreference({ + userId: ctx.user.id, + tableIdentifier: input.tableIdentifier, + segmentId: input.segmentId, }); }; diff --git a/packages/trpc/server/routers/viewer/filterSegments/preference.schema.ts b/packages/trpc/server/routers/viewer/filterSegments/preference.schema.ts deleted file mode 100644 index 489323d74e..0000000000 --- a/packages/trpc/server/routers/viewer/filterSegments/preference.schema.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from "zod"; - -export const ZSetFilterSegmentPreferenceInputSchema = z.object({ - tableIdentifier: z.string(), - segmentId: z.number().nullable(), -}); - -export type TSetFilterSegmentPreferenceInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/filterSegments/types.ts b/packages/trpc/server/routers/viewer/filterSegments/types.ts deleted file mode 100644 index f1078ab8df..0000000000 --- a/packages/trpc/server/routers/viewer/filterSegments/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { FilterSegmentOutput } from "@calcom/features/data-table/lib/types"; - -export type { SortingState, ColumnSizingState, VisibilityState } from "@tanstack/react-table"; -export { - type ActiveFilter, - type FilterSegmentOutput, - ZActiveFilters, - ZSortingState, - ZColumnSizing, - ZColumnVisibility, -} from "@calcom/features/data-table/lib/types"; - -export type FilterSegmentsListResponse = { - segments: FilterSegmentOutput[]; - preferredSegmentId: number | null; -}; diff --git a/packages/trpc/server/routers/viewer/filterSegments/update.handler.ts b/packages/trpc/server/routers/viewer/filterSegments/update.handler.ts index db71b25725..970ccac748 100644 --- a/packages/trpc/server/routers/viewer/filterSegments/update.handler.ts +++ b/packages/trpc/server/routers/viewer/filterSegments/update.handler.ts @@ -1,11 +1,8 @@ -import { prisma } from "@calcom/prisma"; +import { FilterSegmentRepository } from "@calcom/lib/server/repository/filterSegment"; +import type { TUpdateFilterSegmentInputSchema } from "@calcom/lib/server/repository/filterSegment.type"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; -import { TRPCError } from "@trpc/server"; - -import type { TUpdateFilterSegmentInputSchema } from "./update.schema"; - -export const updateHandler = async ({ +export const updateFilterSegmentHandler = async ({ ctx, input, }: { @@ -14,59 +11,9 @@ export const updateHandler = async ({ }; input: TUpdateFilterSegmentInputSchema; }) => { - const { id, name, activeFilters, sorting, columnVisibility, columnSizing, perPage, searchTerm } = input; - const userId = ctx.user.id; - - // First, fetch the existing segment to check permissions - const existingSegment = await prisma.filterSegment.findFirst({ - where: { - id, - ...(input.scope === "TEAM" - ? { - scope: "TEAM", - teamId: input.teamId, - team: { - members: { - some: { - userId, - accepted: true, - role: { - in: ["ADMIN", "OWNER"], - }, - }, - }, - }, - } - : { - scope: "USER", - userId, - }), - }, - select: { - id: true, - }, + const repository = new FilterSegmentRepository(); + return await repository.update({ + userId: ctx.user.id, + input, }); - - if (!existingSegment) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Filter segment not found or you don't have permission to update it", - }); - } - - // Update the filter segment with only the allowed fields - const updatedSegment = await prisma.filterSegment.update({ - where: { id }, - data: { - name, - activeFilters, - sorting, - columnVisibility, - columnSizing, - perPage, - searchTerm, - }, - }); - - return updatedSegment; }; diff --git a/packages/trpc/server/routers/viewer/filterSegments/update.schema.ts b/packages/trpc/server/routers/viewer/filterSegments/update.schema.ts deleted file mode 100644 index 7bc4fbf993..0000000000 --- a/packages/trpc/server/routers/viewer/filterSegments/update.schema.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { z } from "zod"; - -import { ZActiveFilters, ZColumnSizing, ZColumnVisibility, ZSortingState } from "./types"; - -// Base schema for common fields that can be updated -const baseUpdateSchema = { - name: z.string().min(1).optional(), - tableIdentifier: z.string().min(1).optional(), - activeFilters: ZActiveFilters.optional(), - sorting: ZSortingState.optional(), - columnVisibility: ZColumnVisibility.optional(), - columnSizing: ZColumnSizing.optional(), - searchTerm: z.string().nullable().optional(), - perPage: z.number().int().min(1).optional(), -}; - -// Schema for team scope updates -const teamUpdateSchema = z.object({ - id: z.number().int().positive(), - scope: z.literal("TEAM"), - teamId: z.number().int().positive(), - ...baseUpdateSchema, -}); - -// Schema for user scope updates -const userUpdateSchema = z.object({ - id: z.number().int().positive(), - scope: z.literal("USER"), - ...baseUpdateSchema, -}); - -export const ZUpdateFilterSegmentInputSchema = z.discriminatedUnion("scope", [ - teamUpdateSchema, - userUpdateSchema, -]); - -export type TUpdateFilterSegmentInputSchema = z.infer;