feat: booking report table backend (#24794)

* feat: report table backend

* fix: types

* fix: types

* fix: bugs

* fix: type error

---------

Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
This commit is contained in:
Udit TakkarandVolnei Munhoz authored and GitHub committed 2025-11-03 10:44:09 +00:00
1 parent fb55bc2dcd
commit b693b6898a
21 files changed
+1665 -58

No files matched your search

@@ -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<BookingReportSummary[]>;
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<BookingReportWithDetails[]>;
linkWatchlistToReport(params: { reportId: string; watchlistId: string }): Promise<void>;
updateReportStatus(params: {
reportId: string;
status: BookingReportStatus;
organizationId?: number;
}): Promise<void>;
}
@@ -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" },
});
});
});
+178 -6
View File
@@ -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<BookingReportSummary[]> {
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<BookingReportWithDetails[]> {
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<void> {
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<void> {
const where: Prisma.BookingReportWhereInput = {
id: params.reportId,
};
if (params.organizationId) {
where.organizationId = params.organizationId;
}
await this.prismaClient.bookingReport.updateMany({
where,
data: { status: params.status },
});
}
}
@@ -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<void>;
@@ -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<WatchlistEntry | null> {
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 || [],
@@ -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';
+7
View File
@@ -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
@@ -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);
}),
});
@@ -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<typeof PermissionCheckService>
);
vi.mocked(PrismaBookingReportRepository).mockImplementation(
() => mockReportRepo as InstanceType<typeof PrismaBookingReportRepository>
);
vi.mocked(WatchlistRepository).mockImplementation(
() => mockWatchlistRepo as InstanceType<typeof WatchlistRepository>
);
});
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,
});
});
});
});
@@ -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<TrpcSessionUser>;
};
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;
@@ -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<typeof ZAddToWatchlistInputSchema>;
@@ -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);
});
});
});
});
@@ -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({
@@ -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<typeof PermissionCheckService>
);
vi.mocked(PrismaBookingReportRepository).mockImplementation(
() => mockReportRepo as InstanceType<typeof PrismaBookingReportRepository>
);
});
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,
});
});
});
});
@@ -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<TrpcSessionUser>;
};
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;
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZDismissBookingReportInputSchema = z.object({
reportId: z.string().uuid(),
});
export type TDismissBookingReportInputSchema = z.infer<typeof ZDismissBookingReportInputSchema>;
@@ -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();
});
});
});
});
@@ -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({
@@ -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<typeof PermissionCheckService>
);
vi.mocked(PrismaBookingReportRepository).mockImplementation(
() => mockReportRepo as InstanceType<typeof PrismaBookingReportRepository>
);
});
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,
});
});
});
});
@@ -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<TrpcSessionUser>;
};
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;
@@ -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<typeof ZListBookingReportsInputSchema>;