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:
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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
@@ -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>;
|
||||
+8
-8
@@ -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({
|
||||
|
||||
+183
@@ -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>;
|
||||
+15
-15
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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>;
|
||||
Reference in new issue
Block a user