diff --git a/packages/lib/server/repository/bookingReport.interface.ts b/packages/lib/server/repository/bookingReport.interface.ts index 9d74cf9174..f09957ef02 100644 --- a/packages/lib/server/repository/bookingReport.interface.ts +++ b/packages/lib/server/repository/bookingReport.interface.ts @@ -1,4 +1,4 @@ -import type { BookingReportReason } from "@calcom/prisma/enums"; +import type { BookingReportReason, BookingReportStatus } from "@calcom/prisma/enums"; export interface CreateBookingReportInput { bookingUid: string; @@ -18,8 +18,69 @@ export interface BookingReportSummary { createdAt: Date; } +export interface ListBookingReportsFilters { + reason?: BookingReportReason[]; + cancelled?: boolean; + hasWatchlist?: boolean; + status?: BookingReportStatus[]; +} + +export interface BookingReportWithDetails { + id: string; + bookingUid: string; + bookerEmail: string; + reportedById: number | null; + reason: BookingReportReason; + description: string | null; + cancelled: boolean; + createdAt: Date; + status: BookingReportStatus; + watchlistId: string | null; + reporter: { + id: number; + email: string; + name: string | null; + } | null; + booking: { + id: number; + uid: string; + title: string | null; + startTime: Date; + endTime: Date; + }; + watchlist: { + id: string; + type: string; + value: string; + action: string; + description: string | null; + } | null; +} + export interface IBookingReportRepository { createReport(input: CreateBookingReportInput): Promise<{ id: string }>; - findAllReportedBookings(params: { skip?: number; take?: number }): Promise; + findAllReportedBookings(params: { + organizationId?: number; + skip?: number; + take?: number; + searchTerm?: string; + filters?: ListBookingReportsFilters; + }): Promise<{ + rows: BookingReportWithDetails[]; + meta: { totalRowCount: number }; + }>; + + findReportsByIds(params: { + reportIds: string[]; + organizationId?: number; + }): Promise; + + linkWatchlistToReport(params: { reportId: string; watchlistId: string }): Promise; + + updateReportStatus(params: { + reportId: string; + status: BookingReportStatus; + organizationId?: number; + }): Promise; } diff --git a/packages/lib/server/repository/bookingReport.test.ts b/packages/lib/server/repository/bookingReport.test.ts index d185c8606f..50fdd9c217 100644 --- a/packages/lib/server/repository/bookingReport.test.ts +++ b/packages/lib/server/repository/bookingReport.test.ts @@ -19,6 +19,8 @@ describe("PrismaBookingReportRepository", () => { findUnique: vi.fn(), findMany: vi.fn(), findFirst: vi.fn(), + updateMany: vi.fn(), + count: vi.fn(), }, } as unknown as PrismaClient; @@ -94,36 +96,112 @@ describe("PrismaBookingReportRepository", () => { const mockReports = [ { id: "report-1", + bookingUid: "booking-1", + bookerEmail: "user1@example.com", reportedById: 1, reason: BookingReportReason.SPAM, description: "Spam", + cancelled: false, createdAt: new Date("2025-01-01T10:00:00Z"), + status: "PENDING", + watchlistId: null, + reportedBy: null, + booking: { + id: 1, + uid: "booking-1", + title: "Test Booking", + startTime: new Date(), + endTime: new Date(), + }, + watchlist: null, }, { id: "report-2", + bookingUid: "booking-2", + bookerEmail: "user2@example.com", reportedById: 2, reason: BookingReportReason.DONT_KNOW_PERSON, description: null, + cancelled: false, createdAt: new Date("2025-01-01T11:00:00Z"), + status: "PENDING", + watchlistId: null, + reportedBy: null, + booking: { + id: 2, + uid: "booking-2", + title: "Test Booking 2", + startTime: new Date(), + endTime: new Date(), + }, + watchlist: null, }, ]; mockPrisma.bookingReport.findMany.mockResolvedValue(mockReports); + mockPrisma.bookingReport.count.mockResolvedValue(2); const result = await repository.findAllReportedBookings({ skip: 0, take: 10 }); - expect(result).toEqual(mockReports); - expect(mockPrisma.bookingReport.findMany).toHaveBeenCalledWith({ - skip: 0, - take: 10, - select: { - id: true, - reportedById: true, - reason: true, - description: true, - createdAt: true, + expect(result).toEqual({ + rows: mockReports.map((r) => ({ ...r, reporter: r.reportedBy })), + meta: { totalRowCount: 2 }, + }); + expect(mockPrisma.bookingReport.findMany).toHaveBeenCalled(); + expect(mockPrisma.bookingReport.count).toHaveBeenCalled(); + }); + }); + + describe("updateReportStatus", () => { + it("should update report status with organizationId", async () => { + mockPrisma.bookingReport.updateMany.mockResolvedValue({ count: 1 }); + + await repository.updateReportStatus({ + reportId: "report-123", + status: "BLOCKED", + organizationId: 100, + }); + + expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({ + where: { + id: "report-123", + organizationId: 100, }, - orderBy: { createdAt: "desc" }, + data: { status: "BLOCKED" }, + }); + }); + + it("should update report status without organizationId", async () => { + mockPrisma.bookingReport.updateMany.mockResolvedValue({ count: 1 }); + + await repository.updateReportStatus({ + reportId: "report-456", + status: "DISMISSED", + }); + + expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({ + where: { + id: "report-456", + }, + data: { status: "DISMISSED" }, + }); + }); + + it("should update report status to PENDING", async () => { + mockPrisma.bookingReport.updateMany.mockResolvedValue({ count: 1 }); + + await repository.updateReportStatus({ + reportId: "report-789", + status: "PENDING", + organizationId: 200, + }); + + expect(mockPrisma.bookingReport.updateMany).toHaveBeenCalledWith({ + where: { + id: "report-789", + organizationId: 200, + }, + data: { status: "PENDING" }, }); }); }); diff --git a/packages/lib/server/repository/bookingReport.ts b/packages/lib/server/repository/bookingReport.ts index f9ef5c9c2e..d33c9cb27e 100644 --- a/packages/lib/server/repository/bookingReport.ts +++ b/packages/lib/server/repository/bookingReport.ts @@ -1,9 +1,11 @@ import type { PrismaClient } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/generated/prisma/client"; import type { IBookingReportRepository, CreateBookingReportInput, - BookingReportSummary, + BookingReportWithDetails, + ListBookingReportsFilters, } from "./bookingReport.interface"; export class PrismaBookingReportRepository implements IBookingReportRepository { @@ -25,19 +27,189 @@ export class PrismaBookingReportRepository implements IBookingReportRepository { return report; } - async findAllReportedBookings(params: { skip?: number; take?: number }): Promise { + async findAllReportedBookings(params: { + organizationId?: number; + skip?: number; + take?: number; + searchTerm?: string; + filters?: ListBookingReportsFilters; + }): Promise<{ + rows: BookingReportWithDetails[]; + meta: { totalRowCount: number }; + }> { + const where: Prisma.BookingReportWhereInput = {}; + + if (params.organizationId) { + where.organizationId = params.organizationId; + } + + if (params.searchTerm) { + where.OR = [ + { bookerEmail: { contains: params.searchTerm, mode: "insensitive" } }, + { + reportedBy: { + email: { contains: params.searchTerm, mode: "insensitive" }, + }, + }, + ]; + } + + if (params.filters?.hasWatchlist !== undefined) { + where.watchlistId = params.filters.hasWatchlist ? { not: null } : null; + } + + if (params.filters?.reason && params.filters.reason.length > 0) { + where.reason = { in: params.filters.reason }; + } + + if (params.filters?.cancelled !== undefined) { + where.cancelled = params.filters.cancelled; + } + + if (params.filters?.status && params.filters.status.length > 0) { + where.status = { in: params.filters.status }; + } + + const [reports, totalCount] = await Promise.all([ + this.prismaClient.bookingReport.findMany({ + where, + skip: params.skip, + take: params.take, + select: { + id: true, + bookingUid: true, + bookerEmail: true, + reportedById: true, + reason: true, + description: true, + cancelled: true, + createdAt: true, + status: true, + watchlistId: true, + reportedBy: { + select: { + id: true, + email: true, + name: true, + }, + }, + booking: { + select: { + id: true, + uid: true, + title: true, + startTime: true, + endTime: true, + }, + }, + watchlist: { + select: { + id: true, + type: true, + value: true, + action: true, + description: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }), + this.prismaClient.bookingReport.count({ where }), + ]); + + const rows = reports.map((report) => ({ + ...report, + reporter: report.reportedBy, + })); + + return { + rows, + meta: { totalRowCount: totalCount }, + }; + } + + async findReportsByIds(params: { + reportIds: string[]; + organizationId?: number; + }): Promise { + const where: Prisma.BookingReportWhereInput = { + id: { in: params.reportIds }, + }; + + if (params.organizationId) { + where.organizationId = params.organizationId; + } + const reports = await this.prismaClient.bookingReport.findMany({ - skip: params.skip, - take: params.take, + where, select: { id: true, + bookingUid: true, + bookerEmail: true, reportedById: true, reason: true, description: true, + cancelled: true, createdAt: true, + status: true, + watchlistId: true, + reportedBy: { + select: { + id: true, + email: true, + name: true, + }, + }, + booking: { + select: { + id: true, + uid: true, + title: true, + startTime: true, + endTime: true, + }, + }, + watchlist: { + select: { + id: true, + type: true, + value: true, + action: true, + description: true, + }, + }, }, - orderBy: { createdAt: "desc" }, }); - return reports; + + return reports.map((report) => ({ + ...report, + reporter: report.reportedBy, + })); + } + + async linkWatchlistToReport(params: { reportId: string; watchlistId: string }): Promise { + await this.prismaClient.bookingReport.update({ + where: { id: params.reportId }, + data: { watchlistId: params.watchlistId }, + }); + } + + async updateReportStatus(params: { + reportId: string; + status: "PENDING" | "DISMISSED" | "BLOCKED"; + organizationId?: number; + }): Promise { + const where: Prisma.BookingReportWhereInput = { + id: params.reportId, + }; + + if (params.organizationId) { + where.organizationId = params.organizationId; + } + + await this.prismaClient.bookingReport.updateMany({ + where, + data: { status: params.status }, + }); } } diff --git a/packages/lib/server/repository/watchlist.interface.ts b/packages/lib/server/repository/watchlist.interface.ts index ee95d5d79c..377e17f59c 100644 --- a/packages/lib/server/repository/watchlist.interface.ts +++ b/packages/lib/server/repository/watchlist.interface.ts @@ -28,16 +28,18 @@ export interface WatchlistAuditEntry { export interface CreateWatchlistInput { type: WatchlistType; value: string; - organizationId: number; + organizationId: number | null; action: WatchlistAction; description?: string; userId: number; + isGlobal?: boolean; } export interface CheckWatchlistInput { type: WatchlistType; value: string; - organizationId: number; + organizationId?: number | null; + isGlobal?: boolean; } export interface FindAllEntriesInput { @@ -59,8 +61,17 @@ export interface IWatchlistRepository { })[]; meta: { totalRowCount: number }; }>; - findEntryWithAudit(id: string): Promise<{ - entry: WatchlistEntry | null; + findEntryWithAuditAndReports(id: string): Promise<{ + entry: + | (WatchlistEntry & { + bookingReports?: Array<{ + booking: { + uid: string; + title: string | null; + }; + }>; + }) + | null; auditHistory: WatchlistAuditEntry[]; }>; deleteEntry(id: string, userId: number): Promise; diff --git a/packages/lib/server/repository/watchlist.repository.ts b/packages/lib/server/repository/watchlist.repository.ts index e648be6efe..f1bab3be4a 100644 --- a/packages/lib/server/repository/watchlist.repository.ts +++ b/packages/lib/server/repository/watchlist.repository.ts @@ -18,6 +18,7 @@ export class WatchlistRepository implements IWatchlistRepository { type: params.type, value: params.value, organizationId: params.organizationId, + isGlobal: params.isGlobal, }); if (existing) { @@ -33,7 +34,7 @@ export class WatchlistRepository implements IWatchlistRepository { action: params.action, description: params.description, source: WatchlistSource.MANUAL, - isGlobal: false, + isGlobal: params.isGlobal ?? false, }, }); @@ -55,17 +56,35 @@ export class WatchlistRepository implements IWatchlistRepository { } async checkExists(params: CheckWatchlistInput): Promise { - const entry = await this.prismaClient.watchlist.findUnique({ - where: { - type_value_organizationId: { + if (!params.isGlobal && !params.organizationId) { + throw new Error("Both isGlobal and organizationId are missing"); + } + + if (params.isGlobal) { + const entry = await this.prismaClient.watchlist.findFirst({ + where: { type: params.type, value: params.value, - organizationId: params.organizationId, + isGlobal: true, + organizationId: null, }, - }, - }); + }); + return entry; + } else if (params.organizationId) { + const entry = await this.prismaClient.watchlist.findUnique({ + where: { + type_value_organizationId: { + type: params.type, + value: params.value, + organizationId: params.organizationId, + }, + }, + }); - return entry; + return entry; + } + + return null; } async findAllEntries(params: FindAllEntriesInput): Promise<{ @@ -123,8 +142,17 @@ export class WatchlistRepository implements IWatchlistRepository { }; } - async findEntryWithAudit(id: string): Promise<{ - entry: WatchlistEntry | null; + async findEntryWithAuditAndReports(id: string): Promise<{ + entry: + | (WatchlistEntry & { + bookingReports?: Array<{ + booking: { + uid: string; + title: string | null; + }; + }>; + }) + | null; auditHistory: WatchlistAuditEntry[]; }> { const entry = await this.prismaClient.watchlist.findUnique({ @@ -139,6 +167,16 @@ export class WatchlistRepository implements IWatchlistRepository { isGlobal: true, source: true, lastUpdatedAt: true, + bookingReports: { + select: { + booking: { + select: { + uid: true, + title: true, + }, + }, + }, + }, audits: { select: { id: true, @@ -169,6 +207,7 @@ export class WatchlistRepository implements IWatchlistRepository { isGlobal: entry.isGlobal, source: entry.source, lastUpdatedAt: entry.lastUpdatedAt, + bookingReports: entry.bookingReports, } : null, auditHistory: entry?.audits || [], diff --git a/packages/prisma/migrations/20251030081154_add_report_status/migration.sql b/packages/prisma/migrations/20251030081154_add_report_status/migration.sql new file mode 100644 index 0000000000..95ff3ea800 --- /dev/null +++ b/packages/prisma/migrations/20251030081154_add_report_status/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "public"."BookingReportStatus" AS ENUM ('PENDING', 'DISMISSED', 'BLOCKED'); + +-- AlterTable +ALTER TABLE "public"."BookingReport" ADD COLUMN "status" "public"."BookingReportStatus" NOT NULL DEFAULT 'PENDING'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 45f61374eb..cc1f03bdc0 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -2366,6 +2366,12 @@ enum BookingReportReason { OTHER } +enum BookingReportStatus { + PENDING // Initial state - awaiting review + DISMISSED // User clicked "Don't block" + BLOCKED // Added to watchlist +} + model BookingReport { id String @id @default(uuid()) @db.Uuid bookingUid String @unique @@ -2378,6 +2384,7 @@ model BookingReport { cancelled Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + status BookingReportStatus @default(PENDING) watchlistId String? @db.Uuid diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx index e049e041e2..a03c31a377 100644 --- a/packages/trpc/server/routers/viewer/organizations/_router.tsx +++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx @@ -26,6 +26,9 @@ import { ZGetOtherTeamInputSchema } from "./getOtherTeam.handler"; import { ZGetUserInput } from "./getUser.schema"; import { ZGetWatchlistEntryDetailsInputSchema } from "./getWatchlistEntryDetails.schema"; import { ZIntentToCreateOrgInputSchema } from "./intentToCreateOrg.schema"; +import { ZAddToWatchlistInputSchema } from "./addToWatchlist.schema"; +import { ZDismissBookingReportInputSchema } from "./dismissBookingReport.schema"; +import { ZListBookingReportsInputSchema } from "./listBookingReports.schema"; import { ZListMembersInputSchema } from "./listMembers.schema"; import { ZListOtherTeamMembersSchema } from "./listOtherTeamMembers.handler"; import { ZListWatchlistEntriesInputSchema } from "./listWatchlistEntries.schema"; @@ -191,5 +194,23 @@ export const viewerOrganizationsRouter = router({ const { default: handler } = await import("./getWatchlistEntryDetails.handler"); return handler(opts); }), + listBookingReports: authedOrgAdminProcedure + .input(ZListBookingReportsInputSchema) + .query(async (opts) => { + const { default: handler } = await import("./listBookingReports.handler"); + return handler(opts); + }), + addToWatchlist: authedOrgAdminProcedure + .input(ZAddToWatchlistInputSchema) + .mutation(async (opts) => { + const { default: handler } = await import("./addToWatchlist.handler"); + return handler(opts); + }), + dismissBookingReport: authedOrgAdminProcedure + .input(ZDismissBookingReportInputSchema) + .mutation(async (opts) => { + const { default: handler } = await import("./dismissBookingReport.handler"); + return handler(opts); + }), }); diff --git a/packages/trpc/server/routers/viewer/organizations/addToWatchlist.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/addToWatchlist.handler.test.ts new file mode 100644 index 0000000000..c3e1b2d3c5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/addToWatchlist.handler.test.ts @@ -0,0 +1,380 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +import type { WatchlistEntry } from "@calcom/lib/server/repository/watchlist.interface"; +import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport"; +import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository"; +import { + MembershipRole, + WatchlistType, + WatchlistAction, + WatchlistSource, + BookingReportReason, +} from "@calcom/prisma/enums"; + +import { addToWatchlistHandler } from "./addToWatchlist.handler"; + +vi.mock("@calcom/features/pbac/services/permission-check.service"); +vi.mock("@calcom/lib/server/repository/bookingReport"); +vi.mock("@calcom/lib/server/repository/watchlist.repository"); + +describe("addToWatchlistHandler (Organization)", () => { + const mockUser = { + id: 1, + email: "admin@example.com", + organizationId: 100, + profile: null, + }; + + const mockReports = [ + { + id: "report-1", + bookingUid: "booking-uid-1", + bookerEmail: "Spammer@Example.com", + reportedById: 1, + reason: BookingReportReason.SPAM, + description: null, + cancelled: false, + createdAt: new Date(), + watchlistId: null, + reporter: { id: 1, name: "Admin", email: "admin@example.com" }, + booking: { + id: 1, + uid: "booking-uid-1", + title: "Test Booking", + startTime: new Date(), + endTime: new Date(), + }, + watchlist: null, + }, + { + id: "report-2", + bookingUid: "booking-uid-2", + bookerEmail: " User@Spammer.com ", + reportedById: 1, + reason: BookingReportReason.SPAM, + description: null, + cancelled: false, + createdAt: new Date(), + watchlistId: null, + reporter: { id: 1, name: "Admin", email: "admin@example.com" }, + booking: { + id: 2, + uid: "booking-uid-2", + title: "Test Booking 2", + startTime: new Date(), + endTime: new Date(), + }, + watchlist: null, + }, + ]; + + const mockPermissionCheckService = { + checkPermission: vi.fn(), + }; + + const mockReportRepo = { + findReportsByIds: vi.fn(), + linkWatchlistToReport: vi.fn(), + updateReportStatus: vi.fn(), + }; + + const mockWatchlistRepo = { + checkExists: vi.fn(), + createEntry: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(PermissionCheckService).mockImplementation( + () => mockPermissionCheckService as InstanceType + ); + vi.mocked(PrismaBookingReportRepository).mockImplementation( + () => mockReportRepo as InstanceType + ); + vi.mocked(WatchlistRepository).mockImplementation( + () => mockWatchlistRepo as InstanceType + ); + }); + + describe("access control", () => { + it("should throw FORBIDDEN when user is not part of an organization", async () => { + await expect( + addToWatchlistHandler({ + ctx: { user: { ...mockUser, organizationId: undefined, profile: null } }, + input: { + reportIds: ["report-1"], + type: WatchlistType.EMAIL, + }, + }) + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "You must be part of an organization to add to watchlist", + }); + + expect(mockPermissionCheckService.checkPermission).not.toHaveBeenCalled(); + }); + + it("should throw UNAUTHORIZED when user lacks permission", async () => { + mockPermissionCheckService.checkPermission.mockResolvedValue(false); + + await expect( + addToWatchlistHandler({ + ctx: { user: mockUser }, + input: { + reportIds: ["report-1"], + type: WatchlistType.EMAIL, + }, + }) + ).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "You are not authorized to add entries to the watchlist", + }); + + expect(mockReportRepo.findReportsByIds).not.toHaveBeenCalled(); + }); + + it("should check permission with correct parameters", async () => { + mockPermissionCheckService.checkPermission.mockResolvedValue(true); + mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]); + mockWatchlistRepo.checkExists.mockResolvedValue(null); + mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-new" }); + mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined); + mockReportRepo.updateReportStatus.mockResolvedValue(undefined); + + await addToWatchlistHandler({ + ctx: { user: mockUser }, + input: { + reportIds: ["report-1"], + type: WatchlistType.EMAIL, + }, + }); + + expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({ + userId: 1, + teamId: 100, + permission: "watchlist.create", + fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN], + }); + }); + }); + + describe("validation", () => { + beforeEach(() => { + mockPermissionCheckService.checkPermission.mockResolvedValue(true); + }); + + it("should throw NOT_FOUND when report IDs don't exist", async () => { + mockReportRepo.findReportsByIds.mockResolvedValue([]); + + await expect( + addToWatchlistHandler({ + ctx: { user: mockUser }, + input: { + reportIds: ["report-1", "report-2"], + type: WatchlistType.EMAIL, + }, + }) + ).rejects.toMatchObject({ + code: "NOT_FOUND", + message: expect.stringContaining("Booking report(s) not found"), + }); + }); + + it("should throw NOT_FOUND with specific missing IDs", async () => { + mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]); + + await expect( + addToWatchlistHandler({ + ctx: { user: mockUser }, + input: { + reportIds: ["report-1", "report-2", "report-3"], + type: WatchlistType.EMAIL, + }, + }) + ).rejects.toMatchObject({ + code: "NOT_FOUND", + message: expect.stringContaining("report-2, report-3"), + }); + }); + }); + + describe("duplicate prevention", () => { + beforeEach(() => { + mockPermissionCheckService.checkPermission.mockResolvedValue(true); + }); + + it("should throw BAD_REQUEST when all reports are already in watchlist", async () => { + const reportsInWatchlist = mockReports.map((r) => ({ ...r, watchlistId: "watchlist-123" })); + mockReportRepo.findReportsByIds.mockResolvedValue(reportsInWatchlist); + + await expect( + addToWatchlistHandler({ + ctx: { user: mockUser }, + input: { + reportIds: ["report-1", "report-2"], + type: WatchlistType.EMAIL, + }, + }) + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "All selected bookers are already in the watchlist", + }); + }); + }); + + describe("successful addition", () => { + beforeEach(() => { + mockPermissionCheckService.checkPermission.mockResolvedValue(true); + }); + + it("should create new organization watchlist entry for EMAIL type and link report", async () => { + mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]); + mockWatchlistRepo.checkExists.mockResolvedValue(null); + mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-new" }); + mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined); + mockReportRepo.updateReportStatus.mockResolvedValue(undefined); + + const result = await addToWatchlistHandler({ + ctx: { user: mockUser }, + input: { + reportIds: ["report-1"], + type: WatchlistType.EMAIL, + description: "Spam user", + }, + }); + + expect(result.success).toBe(true); + expect(result.addedCount).toBe(1); + expect(mockWatchlistRepo.checkExists).toHaveBeenCalledWith({ + type: WatchlistType.EMAIL, + value: "spammer@example.com", + organizationId: 100, + }); + expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith({ + type: WatchlistType.EMAIL, + value: "spammer@example.com", + organizationId: 100, + action: WatchlistAction.BLOCK, + description: "Spam user", + userId: 1, + }); + expect(mockReportRepo.linkWatchlistToReport).toHaveBeenCalledWith({ + reportId: "report-1", + watchlistId: "watchlist-new", + }); + expect(mockReportRepo.updateReportStatus).toHaveBeenCalledWith({ + reportId: "report-1", + status: "BLOCKED", + organizationId: 100, + }); + }); + + it("should create new organization watchlist entry for DOMAIN type and link report", async () => { + mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]); + mockWatchlistRepo.checkExists.mockResolvedValue(null); + mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-domain" }); + mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined); + mockReportRepo.updateReportStatus.mockResolvedValue(undefined); + + const result = await addToWatchlistHandler({ + ctx: { user: mockUser }, + input: { + reportIds: ["report-1"], + type: WatchlistType.DOMAIN, + }, + }); + + expect(result.success).toBe(true); + expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith({ + type: WatchlistType.DOMAIN, + value: "example.com", + organizationId: 100, + action: WatchlistAction.BLOCK, + description: undefined, + userId: 1, + }); + expect(mockReportRepo.updateReportStatus).toHaveBeenCalledWith({ + reportId: "report-1", + status: "BLOCKED", + organizationId: 100, + }); + }); + + it("should reuse existing organization watchlist entry when value already exists", async () => { + mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]); + mockWatchlistRepo.checkExists.mockResolvedValue({ id: "existing-watchlist" } as WatchlistEntry); + mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined); + mockReportRepo.updateReportStatus.mockResolvedValue(undefined); + + const result = await addToWatchlistHandler({ + ctx: { user: mockUser }, + input: { + reportIds: ["report-1"], + type: WatchlistType.EMAIL, + }, + }); + + expect(result.success).toBe(true); + expect(mockWatchlistRepo.createEntry).not.toHaveBeenCalled(); + expect(mockReportRepo.linkWatchlistToReport).toHaveBeenCalledWith({ + reportId: "report-1", + watchlistId: "existing-watchlist", + }); + expect(mockReportRepo.updateReportStatus).toHaveBeenCalledWith({ + reportId: "report-1", + status: "BLOCKED", + organizationId: 100, + }); + }); + }); + + describe("organization scoping", () => { + beforeEach(() => { + mockPermissionCheckService.checkPermission.mockResolvedValue(true); + }); + + it("should pass organizationId to repository when finding reports", async () => { + mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]); + mockWatchlistRepo.checkExists.mockResolvedValue(null); + mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-new" }); + mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined); + mockReportRepo.updateReportStatus.mockResolvedValue(undefined); + + await addToWatchlistHandler({ + ctx: { user: mockUser }, + input: { + reportIds: ["report-1"], + type: WatchlistType.EMAIL, + }, + }); + + expect(mockReportRepo.findReportsByIds).toHaveBeenCalledWith({ + reportIds: ["report-1"], + organizationId: 100, + }); + }); + + it("should pass organizationId when checking for existing watchlist entry", async () => { + mockReportRepo.findReportsByIds.mockResolvedValue([mockReports[0]]); + mockWatchlistRepo.checkExists.mockResolvedValue(null); + mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-new" }); + mockReportRepo.linkWatchlistToReport.mockResolvedValue(undefined); + mockReportRepo.updateReportStatus.mockResolvedValue(undefined); + + await addToWatchlistHandler({ + ctx: { user: mockUser }, + input: { + reportIds: ["report-1"], + type: WatchlistType.EMAIL, + }, + }); + + expect(mockWatchlistRepo.checkExists).toHaveBeenCalledWith({ + type: WatchlistType.EMAIL, + value: "spammer@example.com", + organizationId: 100, + }); + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/organizations/addToWatchlist.handler.ts b/packages/trpc/server/routers/viewer/organizations/addToWatchlist.handler.ts new file mode 100644 index 0000000000..3133268185 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/addToWatchlist.handler.ts @@ -0,0 +1,138 @@ +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +import { + normalizeEmail, + extractDomainFromEmail, +} from "@calcom/features/watchlist/lib/utils/normalization"; +import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport"; +import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository"; +import { prisma } from "@calcom/prisma"; +import { MembershipRole, WatchlistAction } from "@calcom/prisma/enums"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TAddToWatchlistInputSchema } from "./addToWatchlist.schema"; + +type AddToWatchlistOptions = { + ctx: { + user: NonNullable; + }; + input: TAddToWatchlistInputSchema; +}; + +export const addToWatchlistHandler = async ({ ctx, input }: AddToWatchlistOptions) => { + const { user } = ctx; + + const organizationId = user.profile?.organizationId || user.organizationId; + if (!organizationId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You must be part of an organization to add to watchlist", + }); + } + + const permissionCheckService = new PermissionCheckService(); + const hasPermission = await permissionCheckService.checkPermission({ + userId: user.id, + teamId: organizationId, + permission: "watchlist.create", + fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN], + }); + + if (!hasPermission) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to add entries to the watchlist", + }); + } + + const bookingReportRepo = new PrismaBookingReportRepository(prisma); + const watchlistRepo = new WatchlistRepository(prisma); + + const validReports = await bookingReportRepo.findReportsByIds({ + reportIds: input.reportIds, + organizationId, + }); + + if (validReports.length !== input.reportIds.length) { + const foundIds = validReports.map((r) => r.id); + const missingIds = input.reportIds.filter((id) => !foundIds.includes(id)); + throw new TRPCError({ + code: "NOT_FOUND", + message: `Booking report(s) not found: ${missingIds.join(", ")}`, + }); + } + + const reportsToAdd = validReports.filter((report) => !report.watchlistId); + + if (reportsToAdd.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "All selected bookers are already in the watchlist", + }); + } + + try { + const results = await Promise.all( + reportsToAdd.map(async (report) => { + const value = + input.type === "EMAIL" + ? normalizeEmail(report.bookerEmail) + : extractDomainFromEmail(report.bookerEmail); + + const existingWatchlist = await watchlistRepo.checkExists({ + type: input.type, + value, + organizationId, + }); + + let watchlistId: string; + + if (existingWatchlist) { + watchlistId = existingWatchlist.id; + } else { + const newWatchlist = await watchlistRepo.createEntry({ + type: input.type, + value, + organizationId, + action: WatchlistAction.BLOCK, + description: input.description, + userId: user.id, + }); + watchlistId = newWatchlist.id; + } + + await bookingReportRepo.linkWatchlistToReport({ + reportId: report.id, + watchlistId, + }); + + await bookingReportRepo.updateReportStatus({ + reportId: report.id, + status: "BLOCKED", + organizationId, + }); + + return { reportId: report.id, watchlistId, value }; + }) + ); + + return { + success: true, + message: `Successfully added ${reportsToAdd.length} report(s) to watchlist`, + addedCount: reportsToAdd.length, + skippedCount: validReports.length - reportsToAdd.length, + results: results.map((r) => ({ reportId: r.reportId, watchlistId: r.watchlistId })), + }; + } catch (error) { + if (error instanceof Error && error.message.includes("already exists")) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This entry already exists in the watchlist for your organization", + }); + } + throw error; + } +}; + +export default addToWatchlistHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/addToWatchlist.schema.ts b/packages/trpc/server/routers/viewer/organizations/addToWatchlist.schema.ts new file mode 100644 index 0000000000..b081ef675b --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/addToWatchlist.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +import { WatchlistType } from "@calcom/prisma/enums"; + +export const ZAddToWatchlistInputSchema = z.object({ + reportIds: z.array(z.string().uuid()).min(1), + type: z.nativeEnum(WatchlistType), + description: z.string().optional(), +}); + +export type TAddToWatchlistInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/organizations/deleteWatchlistEntry.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/deleteWatchlistEntry.handler.test.ts index 596468ef1d..d361423ab6 100644 --- a/packages/trpc/server/routers/viewer/organizations/deleteWatchlistEntry.handler.test.ts +++ b/packages/trpc/server/routers/viewer/organizations/deleteWatchlistEntry.handler.test.ts @@ -33,7 +33,7 @@ describe("deleteWatchlistEntryHandler", () => { }; const mockWatchlistRepo = { - findEntryWithAudit: vi.fn(), + findEntryWithAuditAndReports: vi.fn(), deleteEntry: vi.fn(), }; @@ -75,12 +75,12 @@ describe("deleteWatchlistEntryHandler", () => { message: "You are not authorized to delete blocklist entries", }); - expect(mockWatchlistRepo.findEntryWithAudit).not.toHaveBeenCalled(); + expect(mockWatchlistRepo.findEntryWithAuditAndReports).not.toHaveBeenCalled(); }); it("should check permission with correct parameters", async () => { mockPermissionCheckService.checkPermission.mockResolvedValue(true); - mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({ + mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry: mockEntry, auditHistory: [], }); @@ -108,7 +108,7 @@ describe("deleteWatchlistEntryHandler", () => { }); it("should throw NOT_FOUND when entry does not exist", async () => { - mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({ + mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry: null, auditHistory: [], }); @@ -129,7 +129,7 @@ describe("deleteWatchlistEntryHandler", () => { }); it("should throw FORBIDDEN when entry belongs to different organization", async () => { - mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({ + mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry: { ...mockEntry, organizationId: 999 }, auditHistory: [], }); @@ -156,7 +156,7 @@ describe("deleteWatchlistEntryHandler", () => { }); it("should successfully delete entry when authorized", async () => { - mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({ + mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry: mockEntry, auditHistory: [], }); @@ -175,7 +175,7 @@ describe("deleteWatchlistEntryHandler", () => { }); it("should pass correct userId to deleteEntry", async () => { - mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({ + mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry: mockEntry, auditHistory: [], }); @@ -191,4 +191,4 @@ describe("deleteWatchlistEntryHandler", () => { expect(mockWatchlistRepo.deleteEntry).toHaveBeenCalledWith("entry-123", 42); }); }); -}); \ No newline at end of file +}); diff --git a/packages/trpc/server/routers/viewer/organizations/deleteWatchlistEntry.handler.ts b/packages/trpc/server/routers/viewer/organizations/deleteWatchlistEntry.handler.ts index b488fb4ee8..2599e6f540 100644 --- a/packages/trpc/server/routers/viewer/organizations/deleteWatchlistEntry.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/deleteWatchlistEntry.handler.ts @@ -43,7 +43,7 @@ export const deleteWatchlistEntryHandler = async ({ ctx, input }: DeleteWatchlis const watchlistRepo = new WatchlistRepository(prisma); - const { entry } = await watchlistRepo.findEntryWithAudit(input.id); + const { entry } = await watchlistRepo.findEntryWithAuditAndReports(input.id); if (!entry) { throw new TRPCError({ diff --git a/packages/trpc/server/routers/viewer/organizations/dismissBookingReport.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/dismissBookingReport.handler.test.ts new file mode 100644 index 0000000000..1827fd74de --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/dismissBookingReport.handler.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport"; +import { MembershipRole, BookingReportReason } from "@calcom/prisma/enums"; + +import { dismissBookingReportHandler } from "./dismissBookingReport.handler"; + +vi.mock("@calcom/features/pbac/services/permission-check.service"); +vi.mock("@calcom/lib/server/repository/bookingReport"); + +describe("dismissBookingReportHandler", () => { + const mockUser = { + id: 1, + email: "admin@example.com", + organizationId: 100, + profile: null, + }; + + const mockReport = { + id: "report-1", + bookingUid: "booking-uid-1", + bookerEmail: "spammer@example.com", + reportedById: 1, + reason: BookingReportReason.SPAM, + description: null, + cancelled: false, + createdAt: new Date(), + watchlistId: null, + reporter: { id: 1, name: "Admin", email: "admin@example.com" }, + booking: { + id: 1, + uid: "booking-uid-1", + title: "Test Booking", + startTime: new Date(), + endTime: new Date(), + }, + watchlist: null, + }; + + const mockPermissionCheckService = { + checkPermission: vi.fn(), + }; + + const mockReportRepo = { + findReportsByIds: vi.fn(), + updateReportStatus: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(PermissionCheckService).mockImplementation( + () => mockPermissionCheckService as InstanceType + ); + vi.mocked(PrismaBookingReportRepository).mockImplementation( + () => mockReportRepo as InstanceType + ); + }); + + describe("access control", () => { + it("should throw FORBIDDEN when user is not part of an organization", async () => { + const userWithoutOrg = { ...mockUser, organizationId: null, profile: null }; + + await expect( + dismissBookingReportHandler({ + ctx: { user: userWithoutOrg }, + input: { reportId: "report-1" }, + }) + ).rejects.toThrow( + expect.objectContaining({ + code: "FORBIDDEN", + message: "You must be part of an organization to dismiss booking reports", + }) + ); + }); + + it("should throw UNAUTHORIZED when user lacks permission", async () => { + mockPermissionCheckService.checkPermission.mockResolvedValue(false); + + await expect( + dismissBookingReportHandler({ + ctx: { user: mockUser }, + input: { reportId: "report-1" }, + }) + ).rejects.toThrow( + expect.objectContaining({ + code: "UNAUTHORIZED", + message: "You are not authorized to dismiss booking reports", + }) + ); + }); + + it("should check for watchlist.update permission", async () => { + mockPermissionCheckService.checkPermission.mockResolvedValue(true); + mockReportRepo.findReportsByIds.mockResolvedValue([mockReport]); + mockReportRepo.updateReportStatus.mockResolvedValue(undefined); + + await dismissBookingReportHandler({ + ctx: { user: mockUser }, + input: { reportId: "report-1" }, + }); + + expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({ + userId: 1, + teamId: 100, + permission: "watchlist.update", + fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN], + }); + }); + }); + + describe("report verification", () => { + beforeEach(() => { + mockPermissionCheckService.checkPermission.mockResolvedValue(true); + }); + + it("should throw NOT_FOUND when report does not exist", async () => { + mockReportRepo.findReportsByIds.mockResolvedValue([]); + + await expect( + dismissBookingReportHandler({ + ctx: { user: mockUser }, + input: { reportId: "non-existent-report" }, + }) + ).rejects.toThrow( + expect.objectContaining({ + code: "NOT_FOUND", + message: "Booking report not found", + }) + ); + + expect(mockReportRepo.findReportsByIds).toHaveBeenCalledWith({ + reportIds: ["non-existent-report"], + organizationId: 100, + }); + }); + + it("should throw BAD_REQUEST when report already has a watchlist entry", async () => { + const blockedReport = { ...mockReport, watchlistId: "watchlist-123" }; + mockReportRepo.findReportsByIds.mockResolvedValue([blockedReport]); + + await expect( + dismissBookingReportHandler({ + ctx: { user: mockUser }, + input: { reportId: "report-1" }, + }) + ).rejects.toThrow( + expect.objectContaining({ + code: "BAD_REQUEST", + message: "Cannot dismiss a report that has already been added to the blocklist", + }) + ); + + expect(mockReportRepo.updateReportStatus).not.toHaveBeenCalled(); + }); + }); + + describe("successful dismissal", () => { + beforeEach(() => { + mockPermissionCheckService.checkPermission.mockResolvedValue(true); + mockReportRepo.findReportsByIds.mockResolvedValue([mockReport]); + mockReportRepo.updateReportStatus.mockResolvedValue(undefined); + }); + + it("should successfully dismiss a booking report after verifying it belongs to organization", async () => { + const result = await dismissBookingReportHandler({ + ctx: { user: mockUser }, + input: { reportId: "report-1" }, + }); + + expect(result).toEqual({ success: true }); + expect(mockReportRepo.findReportsByIds).toHaveBeenCalledWith({ + reportIds: ["report-1"], + organizationId: 100, + }); + expect(mockReportRepo.updateReportStatus).toHaveBeenCalledWith({ + reportId: "report-1", + status: "DISMISSED", + organizationId: 100, + }); + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/organizations/dismissBookingReport.handler.ts b/packages/trpc/server/routers/viewer/organizations/dismissBookingReport.handler.ts new file mode 100644 index 0000000000..1d4408df90 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/dismissBookingReport.handler.ts @@ -0,0 +1,76 @@ +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport"; +import { prisma } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TDismissBookingReportInputSchema } from "./dismissBookingReport.schema"; + +type DismissBookingReportOptions = { + ctx: { + user: NonNullable; + }; + input: TDismissBookingReportInputSchema; +}; + +export const dismissBookingReportHandler = async ({ ctx, input }: DismissBookingReportOptions) => { + const { user } = ctx; + + const organizationId = user.profile?.organizationId || user.organizationId; + if (!organizationId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You must be part of an organization to dismiss booking reports", + }); + } + + const permissionCheckService = new PermissionCheckService(); + const hasPermission = await permissionCheckService.checkPermission({ + userId: user.id, + teamId: organizationId, + permission: "watchlist.update", + fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN], + }); + + if (!hasPermission) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to dismiss booking reports", + }); + } + + const bookingReportRepo = new PrismaBookingReportRepository(prisma); + + const reports = await bookingReportRepo.findReportsByIds({ + reportIds: [input.reportId], + organizationId, + }); + + if (reports.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Booking report not found", + }); + } + + const report = reports[0]; + + if (report.watchlistId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Cannot dismiss a report that has already been added to the blocklist", + }); + } + + await bookingReportRepo.updateReportStatus({ + reportId: input.reportId, + status: "DISMISSED", + organizationId, + }); + + return { success: true }; +}; + +export default dismissBookingReportHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/dismissBookingReport.schema.ts b/packages/trpc/server/routers/viewer/organizations/dismissBookingReport.schema.ts new file mode 100644 index 0000000000..3fed5e0262 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/dismissBookingReport.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZDismissBookingReportInputSchema = z.object({ + reportId: z.string().uuid(), +}); + +export type TDismissBookingReportInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/organizations/getWatchlistEntryDetails.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/getWatchlistEntryDetails.handler.test.ts index 26e72a4a2d..b31263ab1e 100644 --- a/packages/trpc/server/routers/viewer/organizations/getWatchlistEntryDetails.handler.test.ts +++ b/packages/trpc/server/routers/viewer/organizations/getWatchlistEntryDetails.handler.test.ts @@ -61,7 +61,7 @@ describe("getWatchlistEntryDetailsHandler", () => { }; const mockWatchlistRepo = { - findEntryWithAudit: vi.fn(), + findEntryWithAuditAndReports: vi.fn(), }; const mockUserRepo = { @@ -94,7 +94,7 @@ describe("getWatchlistEntryDetailsHandler", () => { it("should use profile.organizationId when available", async () => { mockPermissionCheckService.checkPermission.mockResolvedValue(true); - mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({ + mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry: { ...mockEntry, organizationId: 200 }, auditHistory: [], }); @@ -135,12 +135,12 @@ describe("getWatchlistEntryDetailsHandler", () => { message: "You are not authorized to view blocklist entries", }); - expect(mockWatchlistRepo.findEntryWithAudit).not.toHaveBeenCalled(); + expect(mockWatchlistRepo.findEntryWithAuditAndReports).not.toHaveBeenCalled(); }); it("should check permission with correct parameters", async () => { mockPermissionCheckService.checkPermission.mockResolvedValue(true); - mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({ + mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry: mockEntry, auditHistory: [], }); @@ -167,7 +167,7 @@ describe("getWatchlistEntryDetailsHandler", () => { }); it("should throw NOT_FOUND when entry does not exist", async () => { - mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({ + mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry: null, auditHistory: [], }); @@ -186,7 +186,7 @@ describe("getWatchlistEntryDetailsHandler", () => { }); it("should throw FORBIDDEN when entry belongs to different organization", async () => { - mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({ + mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry: { ...mockEntry, organizationId: 999 }, auditHistory: [], }); @@ -211,7 +211,7 @@ describe("getWatchlistEntryDetailsHandler", () => { }); it("should return entry and audit history when authorized", async () => { - mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({ + mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry: mockEntry, auditHistory: mockAuditHistory, }); @@ -226,11 +226,11 @@ describe("getWatchlistEntryDetailsHandler", () => { expect(result.entry).toEqual(mockEntry); expect(result.auditHistory).toHaveLength(2); - expect(mockWatchlistRepo.findEntryWithAudit).toHaveBeenCalledWith("entry-123"); + expect(mockWatchlistRepo.findEntryWithAuditAndReports).toHaveBeenCalledWith("entry-123"); }); it("should enrich audit history with user details", async () => { - mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({ + mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry: mockEntry, auditHistory: mockAuditHistory, }); @@ -254,7 +254,7 @@ describe("getWatchlistEntryDetailsHandler", () => { { ...mockAuditHistory[1], changedByUserId: 1 }, ]; - mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({ + mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry: mockEntry, auditHistory: auditWithDuplicates, }); @@ -278,7 +278,7 @@ describe("getWatchlistEntryDetailsHandler", () => { { ...mockAuditHistory[1], changedByUserId: 2 }, ]; - mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({ + mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry: mockEntry, auditHistory: auditWithNulls, }); @@ -302,7 +302,7 @@ describe("getWatchlistEntryDetailsHandler", () => { { ...mockAuditHistory[1], changedByUserId: 2 }, ]; - mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({ + mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry: mockEntry, auditHistory: auditWithUndefined, }); @@ -321,7 +321,7 @@ describe("getWatchlistEntryDetailsHandler", () => { }); it("should not call findUsersByIds when audit history is empty", async () => { - mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({ + mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry: mockEntry, auditHistory: [], }); @@ -338,7 +338,7 @@ describe("getWatchlistEntryDetailsHandler", () => { }); it("should handle when user is not found in user map", async () => { - mockWatchlistRepo.findEntryWithAudit.mockResolvedValue({ + mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({ entry: mockEntry, auditHistory: [mockAuditHistory[0]], }); @@ -354,4 +354,4 @@ describe("getWatchlistEntryDetailsHandler", () => { expect(result.auditHistory[0].changedByUser).toBeUndefined(); }); }); -}); \ No newline at end of file +}); diff --git a/packages/trpc/server/routers/viewer/organizations/getWatchlistEntryDetails.handler.ts b/packages/trpc/server/routers/viewer/organizations/getWatchlistEntryDetails.handler.ts index f95ab19262..36be8c3530 100644 --- a/packages/trpc/server/routers/viewer/organizations/getWatchlistEntryDetails.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/getWatchlistEntryDetails.handler.ts @@ -45,7 +45,7 @@ export const getWatchlistEntryDetailsHandler = async ({ ctx, input }: GetWatchli const watchlistRepo = new WatchlistRepository(prisma); const userRepo = new UserRepository(prisma); - const result = await watchlistRepo.findEntryWithAudit(input.id); + const result = await watchlistRepo.findEntryWithAuditAndReports(input.id); if (!result.entry) { throw new TRPCError({ diff --git a/packages/trpc/server/routers/viewer/organizations/listBookingReports.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/listBookingReports.handler.test.ts new file mode 100644 index 0000000000..a6eb95702b --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/listBookingReports.handler.test.ts @@ -0,0 +1,342 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport"; +import { MembershipRole, BookingReportReason, BookingReportStatus } from "@calcom/prisma/enums"; + +import { listBookingReportsHandler } from "./listBookingReports.handler"; + +vi.mock("@calcom/features/pbac/services/permission-check.service"); +vi.mock("@calcom/lib/server/repository/bookingReport"); + +describe("listBookingReportsHandler (Organization)", () => { + const mockUser = { + id: 1, + email: "admin@example.com", + organizationId: 100, + profile: null, + }; + + const mockReportData = { + rows: [ + { + id: "report-1", + bookingUid: "uid-1", + bookerEmail: "user1@example.com", + reportedById: 1, + reason: BookingReportReason.SPAM, + description: null, + cancelled: false, + createdAt: new Date("2025-01-01"), + status: BookingReportStatus.PENDING, + watchlistId: null, + reporter: { id: 1, name: "Admin", email: "admin@example.com" }, + booking: { + id: 1, + startTime: new Date(), + endTime: new Date(), + title: "Test Booking", + uid: "uid-1", + }, + watchlist: null, + }, + { + id: "report-2", + bookingUid: "uid-2", + bookerEmail: "user2@example.com", + reportedById: 1, + reason: BookingReportReason.DONT_KNOW_PERSON, + description: "Unknown person", + cancelled: true, + createdAt: new Date("2025-01-02"), + status: BookingReportStatus.BLOCKED, + watchlistId: "watchlist-123", + reporter: { id: 1, name: "Admin", email: "admin@example.com" }, + booking: { + id: 2, + startTime: new Date(), + endTime: new Date(), + title: "Test Booking 2", + uid: "uid-2", + }, + watchlist: { + id: "watchlist-123", + type: "EMAIL" as const, + value: "user2@example.com", + action: "REPORT" as const, + description: null, + }, + }, + ], + meta: { totalRowCount: 2 }, + }; + + const mockPermissionCheckService = { + checkPermission: vi.fn(), + }; + + const mockReportRepo = { + findAllReportedBookings: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(PermissionCheckService).mockImplementation( + () => mockPermissionCheckService as InstanceType + ); + vi.mocked(PrismaBookingReportRepository).mockImplementation( + () => mockReportRepo as InstanceType + ); + }); + + describe("access control", () => { + it("should throw FORBIDDEN when user is not part of an organization", async () => { + await expect( + listBookingReportsHandler({ + ctx: { user: { ...mockUser, organizationId: undefined, profile: null } }, + input: { + limit: 10, + offset: 0, + }, + }) + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "You must be part of an organization to view booking reports", + }); + + expect(mockPermissionCheckService.checkPermission).not.toHaveBeenCalled(); + }); + + it("should throw UNAUTHORIZED when user lacks permission", async () => { + mockPermissionCheckService.checkPermission.mockResolvedValue(false); + + await expect( + listBookingReportsHandler({ + ctx: { user: mockUser }, + input: { + limit: 10, + offset: 0, + }, + }) + ).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "You are not authorized to view booking reports", + }); + + expect(mockReportRepo.findAllReportedBookings).not.toHaveBeenCalled(); + }); + + it("should check permission with correct parameters", async () => { + mockPermissionCheckService.checkPermission.mockResolvedValue(true); + mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData); + + await listBookingReportsHandler({ + ctx: { user: mockUser }, + input: { + limit: 10, + offset: 0, + }, + }); + + expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({ + userId: 1, + teamId: 100, + permission: "watchlist.read", + fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN], + }); + }); + }); + + describe("successful listing", () => { + beforeEach(() => { + mockPermissionCheckService.checkPermission.mockResolvedValue(true); + }); + + it("should return paginated booking reports for organization", async () => { + mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData); + + const result = await listBookingReportsHandler({ + ctx: { user: mockUser }, + input: { + limit: 10, + offset: 0, + }, + }); + + expect(result).toEqual(mockReportData); + expect(result.rows).toHaveLength(2); + expect(result.meta.totalRowCount).toBe(2); + expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({ + organizationId: 100, + skip: 0, + take: 10, + searchTerm: undefined, + filters: undefined, + }); + }); + + it("should pass search term to repository", async () => { + mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData); + + await listBookingReportsHandler({ + ctx: { user: mockUser }, + input: { + limit: 10, + offset: 0, + searchTerm: "spam@example.com", + }, + }); + + expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({ + organizationId: 100, + skip: 0, + take: 10, + searchTerm: "spam@example.com", + filters: undefined, + }); + }); + + it("should pass filters to repository", async () => { + mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData); + + const filters = { + reason: [BookingReportReason.SPAM, BookingReportReason.DONT_KNOW_PERSON], + cancelled: true, + hasWatchlist: false, + }; + + await listBookingReportsHandler({ + ctx: { user: mockUser }, + input: { + limit: 20, + offset: 10, + filters, + }, + }); + + expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({ + organizationId: 100, + skip: 10, + take: 20, + searchTerm: undefined, + filters, + }); + }); + + it("should return rows and meta from repository", async () => { + const customData = { + rows: [mockReportData.rows[0]], + meta: { totalRowCount: 1 }, + }; + mockReportRepo.findAllReportedBookings.mockResolvedValue(customData); + + const result = await listBookingReportsHandler({ + ctx: { user: mockUser }, + input: { + limit: 10, + offset: 0, + }, + }); + + expect(result.rows).toHaveLength(1); + expect(result.meta.totalRowCount).toBe(1); + }); + }); + + describe("pagination", () => { + beforeEach(() => { + mockPermissionCheckService.checkPermission.mockResolvedValue(true); + }); + + it("should correctly pass limit and offset to repository", async () => { + mockReportRepo.findAllReportedBookings.mockResolvedValue({ + rows: [], + meta: { totalRowCount: 0 }, + }); + + await listBookingReportsHandler({ + ctx: { user: mockUser }, + input: { + limit: 50, + offset: 100, + }, + }); + + expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({ + organizationId: 100, + skip: 100, + take: 50, + searchTerm: undefined, + filters: undefined, + }); + }); + + it("should handle first page correctly", async () => { + mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData); + + await listBookingReportsHandler({ + ctx: { user: mockUser }, + input: { + limit: 25, + offset: 0, + }, + }); + + expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({ + organizationId: 100, + skip: 0, + take: 25, + searchTerm: undefined, + filters: undefined, + }); + }); + + it("should handle pagination with search and filters", async () => { + mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData); + + await listBookingReportsHandler({ + ctx: { user: mockUser }, + input: { + limit: 15, + offset: 30, + searchTerm: "test@example.com", + filters: { hasWatchlist: false }, + }, + }); + + expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({ + organizationId: 100, + skip: 30, + take: 15, + searchTerm: "test@example.com", + filters: { hasWatchlist: false }, + }); + }); + }); + + describe("organization scoping", () => { + beforeEach(() => { + mockPermissionCheckService.checkPermission.mockResolvedValue(true); + }); + + it("should only return reports from user's organization", async () => { + mockReportRepo.findAllReportedBookings.mockResolvedValue(mockReportData); + + await listBookingReportsHandler({ + ctx: { user: mockUser }, + input: { + limit: 10, + offset: 0, + }, + }); + + expect(mockReportRepo.findAllReportedBookings).toHaveBeenCalledWith({ + organizationId: 100, + skip: 0, + take: 10, + searchTerm: undefined, + filters: undefined, + }); + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/organizations/listBookingReports.handler.ts b/packages/trpc/server/routers/viewer/organizations/listBookingReports.handler.ts new file mode 100644 index 0000000000..42fe29a34e --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/listBookingReports.handler.ts @@ -0,0 +1,57 @@ +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport"; +import { prisma } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TListBookingReportsInputSchema } from "./listBookingReports.schema"; + +type ListBookingReportsOptions = { + ctx: { + user: NonNullable; + }; + input: TListBookingReportsInputSchema; +}; + +export const listBookingReportsHandler = async ({ ctx, input }: ListBookingReportsOptions) => { + const { user } = ctx; + + const organizationId = user.profile?.organizationId || user.organizationId; + if (!organizationId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You must be part of an organization to view booking reports", + }); + } + + const permissionCheckService = new PermissionCheckService(); + const hasPermission = await permissionCheckService.checkPermission({ + userId: user.id, + teamId: organizationId, + permission: "watchlist.read", + fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN], + }); + + if (!hasPermission) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to view booking reports", + }); + } + + const bookingReportRepo = new PrismaBookingReportRepository(prisma); + + const result = await bookingReportRepo.findAllReportedBookings({ + organizationId, + skip: input.offset, + take: input.limit, + searchTerm: input.searchTerm, + filters: input.filters, + }); + + return result; +}; + +export default listBookingReportsHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/listBookingReports.schema.ts b/packages/trpc/server/routers/viewer/organizations/listBookingReports.schema.ts new file mode 100644 index 0000000000..8a787170cb --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/listBookingReports.schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { BookingReportReason, BookingReportStatus } from "@calcom/prisma/enums"; + +export const ZListBookingReportsInputSchema = z.object({ + limit: z.number().min(1).max(100).default(25), + offset: z.number().min(0).default(0), + searchTerm: z.string().optional(), + filters: z + .object({ + reason: z.array(z.nativeEnum(BookingReportReason)).optional(), + cancelled: z.boolean().optional(), + hasWatchlist: z.boolean().optional(), + status: z.array(z.nativeEnum(BookingReportStatus)).optional(), + }) + .optional(), +}); + +export type TListBookingReportsInputSchema = z.infer;