feat: system admin blocklist backend (#25077)
* feat: admin blocklist backend * chore: * refactor: use DI * fix: tests * refactor; improve error * refactor; improve error * perf: use bulk * chore: some more * refactor: feedback * test: update test * refactor: improvements * refactor: improvements * fix: remove * refactor: move to service * refactor: move to service * chore: add logs * refactor: remove default
This commit is contained in:
+2
-2
@@ -50,13 +50,13 @@ export function useBlockedEntriesColumns({
|
||||
header: t("blocked_by"),
|
||||
size: 180,
|
||||
cell: ({ row }) => {
|
||||
const audit = row.original.audits?.[0] as
|
||||
const audit = row.original.latestAudit as
|
||||
| { changedByUserId: number | null }
|
||||
| {
|
||||
changedByUser?: { id: number; email: string; name: string | null } | undefined;
|
||||
changedByUserId: number | null;
|
||||
}
|
||||
| undefined;
|
||||
| null;
|
||||
const email =
|
||||
(audit && "changedByUser" in audit ? audit.changedByUser?.email : undefined) ?? undefined;
|
||||
return <span className="text-default">{email ?? "—"}</span>;
|
||||
|
||||
@@ -5,6 +5,14 @@ export const WATCHLIST_DI_TOKENS = {
|
||||
ORGANIZATION_BLOCKING_SERVICE: Symbol("OrganizationBlockingService"),
|
||||
AUDIT_SERVICE: Symbol("WatchlistAuditService"),
|
||||
|
||||
// Operations services
|
||||
ADMIN_WATCHLIST_OPERATIONS_SERVICE: Symbol("AdminWatchlistOperationsService"),
|
||||
ORGANIZATION_WATCHLIST_OPERATIONS_SERVICE: Symbol("OrganizationWatchlistOperationsService"),
|
||||
|
||||
// Query services
|
||||
ADMIN_WATCHLIST_QUERY_SERVICE: Symbol("AdminWatchlistQueryService"),
|
||||
ORGANIZATION_WATCHLIST_QUERY_SERVICE: Symbol("OrganizationWatchlistQueryService"),
|
||||
|
||||
// Repositories
|
||||
GLOBAL_WATCHLIST_REPOSITORY: Symbol("GlobalWatchlistRepository"),
|
||||
ORGANIZATION_WATCHLIST_REPOSITORY: Symbol("OrganizationWatchlistRepository"),
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { createContainer } from "@evyweb/ioctopus";
|
||||
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { moduleLoader as loggerModuleLoader } from "@calcom/features/di/shared/services/logger.service";
|
||||
import { taskerServiceModule } from "@calcom/features/di/shared/services/tasker.service";
|
||||
import { SHARED_TOKENS } from "@calcom/features/di/shared/shared.tokens";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import {
|
||||
createWatchlistFeature,
|
||||
type WatchlistFeature,
|
||||
} from "@calcom/features/watchlist/lib/facade/WatchlistFeature";
|
||||
import type { IGlobalWatchlistRepository } from "@calcom/features/watchlist/lib/interface/IWatchlistRepositories";
|
||||
import type { IOrganizationWatchlistRepository } from "@calcom/features/watchlist/lib/interface/IWatchlistRepositories";
|
||||
import { AdminWatchlistOperationsService } from "@calcom/features/watchlist/lib/service/AdminWatchlistOperationsService";
|
||||
import { AdminWatchlistQueryService } from "@calcom/features/watchlist/lib/service/AdminWatchlistQueryService";
|
||||
import type { GlobalBlockingService } from "@calcom/features/watchlist/lib/service/GlobalBlockingService";
|
||||
import type { OrganizationBlockingService } from "@calcom/features/watchlist/lib/service/OrganizationBlockingService";
|
||||
import { OrganizationWatchlistOperationsService } from "@calcom/features/watchlist/lib/service/OrganizationWatchlistOperationsService";
|
||||
import { OrganizationWatchlistQueryService } from "@calcom/features/watchlist/lib/service/OrganizationWatchlistQueryService";
|
||||
import type { WatchlistAuditService } from "@calcom/features/watchlist/lib/service/WatchlistAuditService";
|
||||
import type { WatchlistService } from "@calcom/features/watchlist/lib/service/WatchlistService";
|
||||
import { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
|
||||
import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
|
||||
|
||||
import { WATCHLIST_DI_TOKENS } from "../Watchlist.tokens";
|
||||
@@ -64,3 +73,53 @@ export function getOrganizationWatchlistRepository() {
|
||||
export async function getWatchlistFeature(): Promise<WatchlistFeature> {
|
||||
return createWatchlistFeature(watchlistContainer);
|
||||
}
|
||||
|
||||
export function getAdminWatchlistOperationsService(): AdminWatchlistOperationsService {
|
||||
const watchlistRepo = new WatchlistRepository(prisma);
|
||||
const bookingReportRepo = new PrismaBookingReportRepository(prisma);
|
||||
|
||||
return new AdminWatchlistOperationsService({
|
||||
watchlistRepo,
|
||||
bookingReportRepo,
|
||||
});
|
||||
}
|
||||
|
||||
export function getOrganizationWatchlistOperationsService(
|
||||
organizationId: number
|
||||
): OrganizationWatchlistOperationsService {
|
||||
const watchlistRepo = new WatchlistRepository(prisma);
|
||||
const bookingReportRepo = new PrismaBookingReportRepository(prisma);
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
|
||||
return new OrganizationWatchlistOperationsService({
|
||||
watchlistRepo,
|
||||
bookingReportRepo,
|
||||
permissionCheckService,
|
||||
organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
export function getAdminWatchlistQueryService(): AdminWatchlistQueryService {
|
||||
const watchlistRepo = new WatchlistRepository(prisma);
|
||||
const bookingReportRepo = new PrismaBookingReportRepository(prisma);
|
||||
const userRepo = new UserRepository(prisma);
|
||||
|
||||
return new AdminWatchlistQueryService({
|
||||
watchlistRepo,
|
||||
bookingReportRepo,
|
||||
userRepo,
|
||||
prisma,
|
||||
});
|
||||
}
|
||||
|
||||
export function getOrganizationWatchlistQueryService(): OrganizationWatchlistQueryService {
|
||||
const watchlistRepo = new WatchlistRepository(prisma);
|
||||
const userRepo = new UserRepository(prisma);
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
|
||||
return new OrganizationWatchlistQueryService({
|
||||
watchlistRepo,
|
||||
userRepo,
|
||||
permissionCheckService,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
export enum WatchlistErrorCode {
|
||||
NOT_FOUND = "NOT_FOUND",
|
||||
UNAUTHORIZED = "UNAUTHORIZED",
|
||||
ALREADY_IN_WATCHLIST = "ALREADY_IN_WATCHLIST",
|
||||
INVALID_EMAIL = "INVALID_EMAIL",
|
||||
INVALID_DOMAIN = "INVALID_DOMAIN",
|
||||
INVALID_IP = "INVALID_IP",
|
||||
VALIDATION_ERROR = "VALIDATION_ERROR",
|
||||
PERMISSION_DENIED = "PERMISSION_DENIED",
|
||||
DUPLICATE_ENTRY = "DUPLICATE_ENTRY",
|
||||
BULK_DELETE_PARTIAL_FAILURE = "BULK_DELETE_PARTIAL_FAILURE",
|
||||
}
|
||||
|
||||
export class WatchlistError extends Error {
|
||||
constructor(
|
||||
public readonly code: WatchlistErrorCode,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "WatchlistError";
|
||||
}
|
||||
}
|
||||
|
||||
export const WatchlistErrors = {
|
||||
notFound: (message: string) => new WatchlistError(WatchlistErrorCode.NOT_FOUND, message),
|
||||
|
||||
unauthorized: (message: string) => new WatchlistError(WatchlistErrorCode.UNAUTHORIZED, message),
|
||||
|
||||
alreadyInWatchlist: (message: string) =>
|
||||
new WatchlistError(WatchlistErrorCode.ALREADY_IN_WATCHLIST, message),
|
||||
|
||||
invalidEmail: (message: string) => new WatchlistError(WatchlistErrorCode.INVALID_EMAIL, message),
|
||||
|
||||
invalidDomain: (message: string) => new WatchlistError(WatchlistErrorCode.INVALID_DOMAIN, message),
|
||||
|
||||
invalidIp: (message: string) => new WatchlistError(WatchlistErrorCode.INVALID_IP, message),
|
||||
|
||||
validationError: (message: string) =>
|
||||
new WatchlistError(WatchlistErrorCode.VALIDATION_ERROR, message),
|
||||
|
||||
permissionDenied: (message: string) =>
|
||||
new WatchlistError(WatchlistErrorCode.PERMISSION_DENIED, message),
|
||||
|
||||
duplicateEntry: (message: string) =>
|
||||
new WatchlistError(WatchlistErrorCode.DUPLICATE_ENTRY, message),
|
||||
|
||||
bulkDeletePartialFailure: (message: string) =>
|
||||
new WatchlistError(WatchlistErrorCode.BULK_DELETE_PARTIAL_FAILURE, message),
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
import logger from "@calcom/lib/logger";
|
||||
import type { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
|
||||
import type { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
|
||||
import { BookingReportStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { WatchlistErrors } from "../errors/WatchlistErrors";
|
||||
import type {
|
||||
DeleteWatchlistEntryInput,
|
||||
DeleteWatchlistEntryResult,
|
||||
WatchlistOperationsScope,
|
||||
} from "./WatchlistOperationsService";
|
||||
import { WatchlistOperationsService } from "./WatchlistOperationsService";
|
||||
|
||||
export interface BulkDeleteWatchlistEntriesInput {
|
||||
entryIds: string[];
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export interface BulkDeleteWatchlistEntriesResult {
|
||||
success: number;
|
||||
failed: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DismissReportInput {
|
||||
reportId: string;
|
||||
}
|
||||
|
||||
export interface DismissReportResult {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface BulkDismissReportsInput {
|
||||
reportIds: string[];
|
||||
}
|
||||
|
||||
export interface BulkDismissReportsResult {
|
||||
success: number;
|
||||
failed: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
type Deps = {
|
||||
watchlistRepo: WatchlistRepository;
|
||||
bookingReportRepo: PrismaBookingReportRepository;
|
||||
};
|
||||
|
||||
export class AdminWatchlistOperationsService extends WatchlistOperationsService {
|
||||
private adminLog = logger.getSubLogger({ prefix: ["AdminWatchlistOperationsService"] });
|
||||
|
||||
constructor(deps: Deps) {
|
||||
super(deps);
|
||||
}
|
||||
|
||||
protected getScope(): WatchlistOperationsScope {
|
||||
return {
|
||||
organizationId: null,
|
||||
isGlobal: true,
|
||||
};
|
||||
}
|
||||
|
||||
protected async findReports(
|
||||
reportIds: string[]
|
||||
): Promise<Array<{ id: string; bookerEmail: string; watchlistId: string | null }>> {
|
||||
const reports = await this.deps.bookingReportRepo.findReportsByIds({
|
||||
reportIds,
|
||||
});
|
||||
|
||||
return reports;
|
||||
}
|
||||
|
||||
async bulkDeleteWatchlistEntries(
|
||||
input: BulkDeleteWatchlistEntriesInput
|
||||
): Promise<BulkDeleteWatchlistEntriesResult> {
|
||||
const entries = await this.deps.watchlistRepo.findEntriesByIds(input.entryIds);
|
||||
|
||||
const entryMap = new Map(entries.map((e) => [e.id, e]));
|
||||
const validIds: string[] = [];
|
||||
const failed: Array<{ id: string; reason: string }> = [];
|
||||
|
||||
for (const id of input.entryIds) {
|
||||
const entry = entryMap.get(id);
|
||||
|
||||
if (!entry) {
|
||||
failed.push({ id, reason: "Entry not found" });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isGlobal || entry.organizationId !== null) {
|
||||
failed.push({ id, reason: "Can only delete system blocklist entries" });
|
||||
continue;
|
||||
}
|
||||
|
||||
validIds.push(id);
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
if (validIds.length > 0) {
|
||||
const result = await this.deps.watchlistRepo.bulkDeleteEntries({
|
||||
ids: validIds,
|
||||
userId: input.userId,
|
||||
});
|
||||
successCount = result.deleted;
|
||||
}
|
||||
|
||||
if (successCount === 0 && failed.length > 0) {
|
||||
this.adminLog.error("Bulk delete watchlist entries failures", { failed });
|
||||
throw WatchlistErrors.bulkDeletePartialFailure(`Failed to delete all entries: ${failed[0].reason}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: successCount,
|
||||
failed: failed.length,
|
||||
// TODO: use translate keys in follow up frontend PR
|
||||
message:
|
||||
failed.length === 0
|
||||
? "All entries deleted successfully"
|
||||
: `Deleted ${successCount} entries, ${failed.length} failed`,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteWatchlistEntry(input: DeleteWatchlistEntryInput): Promise<DeleteWatchlistEntryResult> {
|
||||
const { entry } = await this.deps.watchlistRepo.findEntryWithAuditAndReports(input.entryId);
|
||||
|
||||
if (!entry) {
|
||||
throw WatchlistErrors.notFound("Blocklist entry not found");
|
||||
}
|
||||
|
||||
if (!entry.isGlobal || entry.organizationId !== null) {
|
||||
throw WatchlistErrors.permissionDenied("You can only delete system blocklist entries");
|
||||
}
|
||||
|
||||
await this.deps.watchlistRepo.deleteEntry(input.entryId, input.userId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Entry deleted successfully",
|
||||
};
|
||||
}
|
||||
|
||||
async dismissReport(input: DismissReportInput): Promise<DismissReportResult> {
|
||||
const reports = await this.deps.bookingReportRepo.findReportsByIds({
|
||||
reportIds: [input.reportId],
|
||||
});
|
||||
|
||||
if (reports.length === 0) {
|
||||
throw WatchlistErrors.notFound("Report not found");
|
||||
}
|
||||
|
||||
const report = reports[0];
|
||||
|
||||
if (report.watchlistId) {
|
||||
throw WatchlistErrors.validationError(
|
||||
"Cannot dismiss a report that has already been added to the blocklist"
|
||||
);
|
||||
}
|
||||
|
||||
await this.deps.bookingReportRepo.updateReportStatus({
|
||||
reportId: input.reportId,
|
||||
status: BookingReportStatus.DISMISSED,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async bulkDismissReports(input: BulkDismissReportsInput): Promise<BulkDismissReportsResult> {
|
||||
const reports = await this.deps.bookingReportRepo.findReportsByIds({
|
||||
reportIds: input.reportIds,
|
||||
});
|
||||
|
||||
const reportMap = new Map(reports.map((r) => [r.id, r]));
|
||||
const validReportIds: string[] = [];
|
||||
const failed: Array<{ id: string; reason: string }> = [];
|
||||
|
||||
for (const reportId of input.reportIds) {
|
||||
const report = reportMap.get(reportId);
|
||||
|
||||
if (!report) {
|
||||
failed.push({ id: reportId, reason: "Report not found" });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (report.watchlistId) {
|
||||
failed.push({ id: reportId, reason: "Already added to blocklist" });
|
||||
continue;
|
||||
}
|
||||
|
||||
validReportIds.push(reportId);
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
if (validReportIds.length > 0) {
|
||||
const result = await this.deps.bookingReportRepo.bulkUpdateReportStatus({
|
||||
reportIds: validReportIds,
|
||||
status: BookingReportStatus.DISMISSED,
|
||||
});
|
||||
successCount = result.updated;
|
||||
}
|
||||
|
||||
if (successCount === 0 && failed.length > 0) {
|
||||
this.adminLog.error("Bulk dismiss reports failures", { failed });
|
||||
throw WatchlistErrors.bulkDeletePartialFailure(`Failed to dismiss all reports: ${failed[0].reason}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: successCount,
|
||||
failed: failed.length,
|
||||
message:
|
||||
failed.length === 0
|
||||
? "All reports dismissed successfully"
|
||||
: `Dismissed ${successCount} reports, ${failed.length} failed`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import type { ListBookingReportsFilters } from "@calcom/lib/server/repository/bookingReport.interface";
|
||||
import type { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
|
||||
import type { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { WatchlistType, WatchlistSource } from "@calcom/prisma/enums";
|
||||
|
||||
import { WatchlistErrors } from "../errors/WatchlistErrors";
|
||||
|
||||
export interface ListWatchlistEntriesInput {
|
||||
limit: number;
|
||||
offset: number;
|
||||
searchTerm?: string;
|
||||
filters?: {
|
||||
type?: WatchlistType;
|
||||
source?: WatchlistSource;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetWatchlistEntryDetailsInput {
|
||||
entryId: string;
|
||||
}
|
||||
|
||||
export interface ListBookingReportsInput {
|
||||
limit: number;
|
||||
offset: number;
|
||||
searchTerm?: string;
|
||||
filters?: ListBookingReportsFilters;
|
||||
}
|
||||
|
||||
type Deps = {
|
||||
watchlistRepo: WatchlistRepository;
|
||||
bookingReportRepo: PrismaBookingReportRepository;
|
||||
userRepo: UserRepository;
|
||||
prisma: PrismaClient;
|
||||
};
|
||||
|
||||
export class AdminWatchlistQueryService {
|
||||
constructor(private readonly deps: Deps) {}
|
||||
|
||||
async listWatchlistEntries(input: ListWatchlistEntriesInput) {
|
||||
const { rows, meta } = await this.deps.watchlistRepo.findAllEntriesWithLatestAudit({
|
||||
organizationId: null,
|
||||
isGlobal: true,
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
searchTerm: input.searchTerm,
|
||||
filters: input.filters,
|
||||
});
|
||||
|
||||
const userIds = rows
|
||||
.map((entry) => entry.latestAudit?.changedByUserId)
|
||||
.filter((id): id is number => id !== null && id !== undefined);
|
||||
|
||||
const uniqueUserIds = Array.from(new Set(userIds));
|
||||
|
||||
const users = uniqueUserIds.length > 0 ? await this.deps.userRepo.findUsersByIds(uniqueUserIds) : [];
|
||||
|
||||
const userMap = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
const rowsWithCreators = rows.map((entry) => {
|
||||
if (entry.latestAudit?.changedByUserId) {
|
||||
const changedByUser = userMap.get(entry.latestAudit.changedByUserId);
|
||||
return {
|
||||
...entry,
|
||||
latestAudit: {
|
||||
...entry.latestAudit,
|
||||
changedByUser,
|
||||
},
|
||||
};
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
return {
|
||||
rows: rowsWithCreators,
|
||||
meta,
|
||||
};
|
||||
}
|
||||
|
||||
async getWatchlistEntryDetails(input: GetWatchlistEntryDetailsInput) {
|
||||
const result = await this.deps.watchlistRepo.findEntryWithAuditAndReports(input.entryId);
|
||||
|
||||
if (!result.entry) {
|
||||
throw WatchlistErrors.notFound("Blocklist entry not found");
|
||||
}
|
||||
|
||||
if (!result.entry.isGlobal || result.entry.organizationId !== null) {
|
||||
throw WatchlistErrors.permissionDenied("You can only view system blocklist entries");
|
||||
}
|
||||
|
||||
const userIds = result.auditHistory
|
||||
.map((audit) => audit.changedByUserId)
|
||||
.filter((id): id is number => id !== null && id !== undefined);
|
||||
|
||||
const uniqueUserIds = Array.from(new Set(userIds));
|
||||
|
||||
const users = uniqueUserIds.length > 0 ? await this.deps.userRepo.findUsersByIds(uniqueUserIds) : [];
|
||||
|
||||
const userMap = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
const auditHistoryWithUsers = result.auditHistory.map((audit) => ({
|
||||
...audit,
|
||||
changedByUser: audit.changedByUserId ? userMap.get(audit.changedByUserId) : undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
entry: result.entry,
|
||||
auditHistory: auditHistoryWithUsers,
|
||||
};
|
||||
}
|
||||
|
||||
async listBookingReports(input: ListBookingReportsInput) {
|
||||
const result = await this.deps.bookingReportRepo.findAllReportedBookings({
|
||||
skip: input.offset,
|
||||
take: input.limit,
|
||||
searchTerm: input.searchTerm,
|
||||
filters: input.filters,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getPendingReportsCount(): Promise<number> {
|
||||
return this.deps.prisma.bookingReport.count({
|
||||
where: {
|
||||
status: "PENDING",
|
||||
watchlistId: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import type { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
|
||||
import type { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { WatchlistErrors } from "../errors/WatchlistErrors";
|
||||
import type {
|
||||
AddReportsToWatchlistInput,
|
||||
AddReportsToWatchlistResult,
|
||||
CreateWatchlistEntryInput,
|
||||
CreateWatchlistEntryResult,
|
||||
DeleteWatchlistEntryInput,
|
||||
DeleteWatchlistEntryResult,
|
||||
WatchlistOperationsScope,
|
||||
} from "./WatchlistOperationsService";
|
||||
import { WatchlistOperationsService } from "./WatchlistOperationsService";
|
||||
|
||||
type Deps = {
|
||||
watchlistRepo: WatchlistRepository;
|
||||
bookingReportRepo: PrismaBookingReportRepository;
|
||||
permissionCheckService: PermissionCheckService;
|
||||
organizationId: number;
|
||||
};
|
||||
|
||||
export class OrganizationWatchlistOperationsService extends WatchlistOperationsService {
|
||||
private readonly permissionCheckService: PermissionCheckService;
|
||||
private readonly organizationId: number;
|
||||
|
||||
constructor(deps: Deps) {
|
||||
super(deps);
|
||||
this.permissionCheckService = deps.permissionCheckService;
|
||||
this.organizationId = deps.organizationId;
|
||||
}
|
||||
|
||||
protected getScope(): WatchlistOperationsScope {
|
||||
return {
|
||||
organizationId: this.organizationId,
|
||||
isGlobal: false,
|
||||
};
|
||||
}
|
||||
|
||||
protected async findReports(
|
||||
reportIds: string[]
|
||||
): Promise<Array<{ id: string; bookerEmail: string; watchlistId: string | null }>> {
|
||||
const reports = await this.deps.bookingReportRepo.findReportsByIds({
|
||||
reportIds,
|
||||
organizationId: this.organizationId,
|
||||
});
|
||||
|
||||
return reports;
|
||||
}
|
||||
|
||||
private async checkPermission(userId: number, permission: PermissionString): Promise<void> {
|
||||
const hasPermission = await this.permissionCheckService.checkPermission({
|
||||
userId,
|
||||
teamId: this.organizationId,
|
||||
permission,
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
throw WatchlistErrors.permissionDenied(`You are not authorized to ${permission.split(".")[1]} watchlist entries`);
|
||||
}
|
||||
}
|
||||
|
||||
async addReportsToWatchlist(input: AddReportsToWatchlistInput): Promise<AddReportsToWatchlistResult> {
|
||||
await this.checkPermission(input.userId, "watchlist.create");
|
||||
return super.addReportsToWatchlist(input);
|
||||
}
|
||||
|
||||
async createWatchlistEntry(input: CreateWatchlistEntryInput): Promise<CreateWatchlistEntryResult> {
|
||||
await this.checkPermission(input.userId, "watchlist.create");
|
||||
return super.createWatchlistEntry(input);
|
||||
}
|
||||
|
||||
async deleteWatchlistEntry(input: DeleteWatchlistEntryInput): Promise<DeleteWatchlistEntryResult> {
|
||||
await this.checkPermission(input.userId, "watchlist.delete");
|
||||
return super.deleteWatchlistEntry(input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import type { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import type { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
|
||||
import { MembershipRole, WatchlistType, WatchlistSource } from "@calcom/prisma/enums";
|
||||
|
||||
import { WatchlistErrors } from "../errors/WatchlistErrors";
|
||||
|
||||
export interface ListWatchlistEntriesInput {
|
||||
organizationId: number;
|
||||
userId: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
searchTerm?: string;
|
||||
filters?: {
|
||||
type?: WatchlistType;
|
||||
source?: WatchlistSource;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetWatchlistEntryDetailsInput {
|
||||
organizationId: number;
|
||||
userId: number;
|
||||
entryId: string;
|
||||
}
|
||||
|
||||
type Deps = {
|
||||
watchlistRepo: WatchlistRepository;
|
||||
userRepo: UserRepository;
|
||||
permissionCheckService: PermissionCheckService;
|
||||
};
|
||||
|
||||
export class OrganizationWatchlistQueryService {
|
||||
constructor(private readonly deps: Deps) {}
|
||||
|
||||
private async checkReadPermission(userId: number, organizationId: number): Promise<void> {
|
||||
const hasPermission = await this.deps.permissionCheckService.checkPermission({
|
||||
userId,
|
||||
teamId: organizationId,
|
||||
permission: "watchlist.read",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
throw WatchlistErrors.permissionDenied("You are not authorized to view watchlist entries");
|
||||
}
|
||||
}
|
||||
|
||||
async listWatchlistEntries(input: ListWatchlistEntriesInput) {
|
||||
await this.checkReadPermission(input.userId, input.organizationId);
|
||||
|
||||
const result = await this.deps.watchlistRepo.findAllEntriesWithLatestAudit({
|
||||
organizationId: input.organizationId,
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
searchTerm: input.searchTerm,
|
||||
filters: input.filters,
|
||||
});
|
||||
|
||||
const userIds = result.rows
|
||||
.map((entry) => entry.latestAudit?.changedByUserId)
|
||||
.filter((id): id is number => id !== null && id !== undefined);
|
||||
|
||||
const uniqueUserIds = Array.from(new Set(userIds));
|
||||
|
||||
const users = uniqueUserIds.length > 0 ? await this.deps.userRepo.findUsersByIds(uniqueUserIds) : [];
|
||||
|
||||
const userMap = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
const rowsWithCreators = result.rows.map((entry) => {
|
||||
if (entry.latestAudit?.changedByUserId) {
|
||||
const changedByUser = userMap.get(entry.latestAudit.changedByUserId);
|
||||
return {
|
||||
...entry,
|
||||
latestAudit: {
|
||||
...entry.latestAudit,
|
||||
changedByUser,
|
||||
},
|
||||
};
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
return {
|
||||
rows: rowsWithCreators,
|
||||
meta: result.meta,
|
||||
};
|
||||
}
|
||||
|
||||
async getWatchlistEntryDetails(input: GetWatchlistEntryDetailsInput) {
|
||||
await this.checkReadPermission(input.userId, input.organizationId);
|
||||
|
||||
const result = await this.deps.watchlistRepo.findEntryWithAuditAndReports(input.entryId);
|
||||
|
||||
if (!result.entry) {
|
||||
throw WatchlistErrors.notFound("Blocklist entry not found");
|
||||
}
|
||||
|
||||
if (result.entry.organizationId !== input.organizationId) {
|
||||
throw WatchlistErrors.permissionDenied("You can only view blocklist entries from your organization");
|
||||
}
|
||||
|
||||
const userIds = result.auditHistory
|
||||
.map((audit) => audit.changedByUserId)
|
||||
.filter((id): id is number => id !== null && id !== undefined);
|
||||
|
||||
const uniqueUserIds = Array.from(new Set(userIds));
|
||||
|
||||
const users = uniqueUserIds.length > 0 ? await this.deps.userRepo.findUsersByIds(uniqueUserIds) : [];
|
||||
|
||||
const userMap = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
const auditHistoryWithUsers = result.auditHistory.map((audit) => ({
|
||||
...audit,
|
||||
changedByUser: audit.changedByUserId ? userMap.get(audit.changedByUserId) : undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
entry: result.entry,
|
||||
auditHistory: auditHistoryWithUsers,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { domainRegex, emailRegex } from "@calcom/lib/emailSchema";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import type { PrismaBookingReportRepository } from "@calcom/lib/server/repository/bookingReport";
|
||||
import type { WatchlistEntry } from "@calcom/lib/server/repository/watchlist.interface";
|
||||
import type { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
|
||||
import { WatchlistAction, WatchlistType } from "@calcom/prisma/enums";
|
||||
|
||||
import { WatchlistErrors } from "../errors/WatchlistErrors";
|
||||
import { extractDomainFromEmail, normalizeEmail } from "../utils/normalization";
|
||||
|
||||
export interface AddReportsToWatchlistInput {
|
||||
reportIds: string[];
|
||||
type: WatchlistType;
|
||||
description?: string;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export interface AddReportsToWatchlistResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
addedCount: number;
|
||||
skippedCount: number;
|
||||
results: Array<{ reportId: string; watchlistId: string }>;
|
||||
}
|
||||
|
||||
export interface CreateWatchlistEntryInput {
|
||||
type: WatchlistType;
|
||||
value: string;
|
||||
description?: string;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export interface CreateWatchlistEntryResult {
|
||||
success: boolean;
|
||||
entry: WatchlistEntry;
|
||||
}
|
||||
|
||||
export interface DeleteWatchlistEntryInput {
|
||||
entryId: string;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export interface DeleteWatchlistEntryResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface WatchlistOperationsScope {
|
||||
organizationId: number | null;
|
||||
isGlobal: boolean;
|
||||
}
|
||||
|
||||
type Deps = {
|
||||
watchlistRepo: WatchlistRepository;
|
||||
bookingReportRepo: PrismaBookingReportRepository;
|
||||
};
|
||||
|
||||
export abstract class WatchlistOperationsService {
|
||||
private log = logger.getSubLogger({ prefix: ["WatchlistOperationsService"] });
|
||||
|
||||
constructor(protected readonly deps: Deps) {}
|
||||
|
||||
protected abstract getScope(): WatchlistOperationsScope;
|
||||
|
||||
protected abstract findReports(
|
||||
reportIds: string[]
|
||||
): Promise<Array<{ id: string; bookerEmail: string; watchlistId: string | null }>>;
|
||||
|
||||
validateEmailOrDomain(type: WatchlistType, value: string): void {
|
||||
if (type === WatchlistType.EMAIL && !emailRegex.test(value)) {
|
||||
throw WatchlistErrors.invalidEmail("Invalid email address format");
|
||||
}
|
||||
|
||||
if (type === WatchlistType.DOMAIN && !domainRegex.test(value)) {
|
||||
throw WatchlistErrors.invalidDomain("Invalid domain format (e.g., example.com)");
|
||||
}
|
||||
}
|
||||
|
||||
async addReportsToWatchlist(input: AddReportsToWatchlistInput): Promise<AddReportsToWatchlistResult> {
|
||||
const scope = this.getScope();
|
||||
|
||||
const validReports = await this.findReports(input.reportIds);
|
||||
|
||||
if (validReports.length !== input.reportIds.length) {
|
||||
const foundIds = new Set(validReports.map((r) => r.id));
|
||||
const missingIds = input.reportIds.filter((id) => !foundIds.has(id));
|
||||
throw WatchlistErrors.notFound(`Report(s) not found: ${missingIds.join(", ")}`);
|
||||
}
|
||||
|
||||
const reportsToAdd = validReports.filter((report) => !report.watchlistId);
|
||||
|
||||
if (reportsToAdd.length === 0) {
|
||||
throw WatchlistErrors.alreadyInWatchlist("All selected reports are already in the watchlist");
|
||||
}
|
||||
|
||||
const normalizedValues = new Map<string, string>();
|
||||
try {
|
||||
for (const report of reportsToAdd) {
|
||||
const value =
|
||||
input.type === WatchlistType.EMAIL
|
||||
? normalizeEmail(report.bookerEmail)
|
||||
: extractDomainFromEmail(report.bookerEmail);
|
||||
normalizedValues.set(report.id, value);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log.error("Email normalization failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
reportIds: reportsToAdd.map((r) => r.id),
|
||||
});
|
||||
throw WatchlistErrors.invalidEmail(error instanceof Error ? error.message : "Invalid email format");
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
reportsToAdd.map(async (report) => {
|
||||
const normalizedValue = normalizedValues.get(report.id);
|
||||
if (!normalizedValue) {
|
||||
throw new Error("Unable to process the selected report. Please try again.");
|
||||
}
|
||||
|
||||
const { watchlistEntry } = await this.deps.watchlistRepo.createEntryFromReport({
|
||||
type: input.type,
|
||||
value: normalizedValue,
|
||||
organizationId: scope.organizationId,
|
||||
isGlobal: scope.isGlobal,
|
||||
userId: input.userId,
|
||||
description: input.description,
|
||||
});
|
||||
|
||||
return {
|
||||
reportId: report.id,
|
||||
watchlistId: watchlistEntry.id,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
await this.deps.bookingReportRepo.bulkLinkWatchlistWithStatus({
|
||||
links: results.map((r) => ({ reportId: r.reportId, watchlistId: r.watchlistId })),
|
||||
status: "BLOCKED",
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully added ${results.length} report(s) to watchlist`,
|
||||
addedCount: results.length,
|
||||
skippedCount: validReports.length - reportsToAdd.length,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
async createWatchlistEntry(input: CreateWatchlistEntryInput): Promise<CreateWatchlistEntryResult> {
|
||||
const scope = this.getScope();
|
||||
|
||||
this.validateEmailOrDomain(input.type, input.value);
|
||||
|
||||
const entry = await this.deps.watchlistRepo.createEntryIfNotExists({
|
||||
type: input.type,
|
||||
value: input.value.toLowerCase(),
|
||||
organizationId: scope.organizationId,
|
||||
action: WatchlistAction.BLOCK,
|
||||
description: input.description,
|
||||
userId: input.userId,
|
||||
isGlobal: scope.isGlobal,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
entry,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteWatchlistEntry(input: DeleteWatchlistEntryInput): Promise<DeleteWatchlistEntryResult> {
|
||||
await this.deps.watchlistRepo.deleteEntry(input.entryId, input.userId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Entry deleted successfully",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,11 @@ export interface BookingReportWithDetails {
|
||||
action: string;
|
||||
description: string | null;
|
||||
} | null;
|
||||
organization: {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface IBookingReportRepository {
|
||||
@@ -84,5 +89,16 @@ export interface IBookingReportRepository {
|
||||
organizationId?: number;
|
||||
}): Promise<void>;
|
||||
|
||||
bulkUpdateReportStatus(params: {
|
||||
reportIds: string[];
|
||||
status: BookingReportStatus;
|
||||
organizationId?: number;
|
||||
}): Promise<{ updated: number }>;
|
||||
|
||||
bulkLinkWatchlistWithStatus(params: {
|
||||
links: Array<{ reportId: string; watchlistId: string }>;
|
||||
status: BookingReportStatus;
|
||||
}): Promise<void>;
|
||||
|
||||
countPendingReports(params: { organizationId: number }): Promise<number>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { BookingReportStatus } from "@calcom/prisma/enums";
|
||||
import type { Prisma } from "@calcom/prisma/generated/prisma/client";
|
||||
|
||||
import type {
|
||||
@@ -86,6 +87,7 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
|
||||
createdAt: true,
|
||||
status: true,
|
||||
watchlistId: true,
|
||||
organizationId: true,
|
||||
reportedBy: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -111,6 +113,13 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
@@ -153,6 +162,7 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
|
||||
createdAt: true,
|
||||
status: true,
|
||||
watchlistId: true,
|
||||
organizationId: true,
|
||||
reportedBy: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -178,6 +188,13 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -196,7 +213,7 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
|
||||
|
||||
async updateReportStatus(params: {
|
||||
reportId: string;
|
||||
status: "PENDING" | "DISMISSED" | "BLOCKED";
|
||||
status: BookingReportStatus;
|
||||
organizationId?: number;
|
||||
}): Promise<void> {
|
||||
const where: Prisma.BookingReportWhereInput = {
|
||||
@@ -213,6 +230,50 @@ export class PrismaBookingReportRepository implements IBookingReportRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async bulkUpdateReportStatus(params: {
|
||||
reportIds: string[];
|
||||
status: BookingReportStatus;
|
||||
organizationId?: number;
|
||||
}): Promise<{ updated: number }> {
|
||||
const where: Prisma.BookingReportWhereInput = {
|
||||
id: { in: params.reportIds },
|
||||
};
|
||||
|
||||
if (params.organizationId !== undefined) {
|
||||
where.organizationId = params.organizationId;
|
||||
}
|
||||
|
||||
const result = await this.prismaClient.bookingReport.updateMany({
|
||||
where,
|
||||
data: { status: params.status },
|
||||
});
|
||||
|
||||
return { updated: result.count };
|
||||
}
|
||||
|
||||
async bulkLinkWatchlistWithStatus(params: {
|
||||
links: Array<{ reportId: string; watchlistId: string }>;
|
||||
status: BookingReportStatus;
|
||||
}): Promise<void> {
|
||||
if (params.links.length === 0) return;
|
||||
|
||||
const groupedByWatchlist = new Map<string, string[]>();
|
||||
for (const link of params.links) {
|
||||
const reportIds = groupedByWatchlist.get(link.watchlistId) || [];
|
||||
reportIds.push(link.reportId);
|
||||
groupedByWatchlist.set(link.watchlistId, reportIds);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Array.from(groupedByWatchlist.entries()).map(([watchlistId, reportIds]) =>
|
||||
this.prismaClient.bookingReport.updateMany({
|
||||
where: { id: { in: reportIds } },
|
||||
data: { watchlistId, status: params.status },
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async countPendingReports(params: { organizationId: number }): Promise<number> {
|
||||
return this.prismaClient.bookingReport.count({
|
||||
where: {
|
||||
|
||||
@@ -43,21 +43,24 @@ export interface CheckWatchlistInput {
|
||||
}
|
||||
|
||||
export interface FindAllEntriesInput {
|
||||
organizationId: number;
|
||||
organizationId?: number | null;
|
||||
isGlobal?: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
searchTerm?: string;
|
||||
filters?: {
|
||||
type?: WatchlistType;
|
||||
source?: WatchlistSource;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IWatchlistRepository {
|
||||
createEntry(params: CreateWatchlistInput): Promise<WatchlistEntry>;
|
||||
createEntryIfNotExists(params: CreateWatchlistInput): Promise<WatchlistEntry>;
|
||||
checkExists(params: CheckWatchlistInput): Promise<WatchlistEntry | null>;
|
||||
findAllEntries(params: FindAllEntriesInput): Promise<{
|
||||
findAllEntriesWithLatestAudit(params: FindAllEntriesInput): Promise<{
|
||||
rows: (WatchlistEntry & {
|
||||
audits?: { changedByUserId: number | null }[];
|
||||
latestAudit: { changedByUserId: number | null } | null;
|
||||
})[];
|
||||
meta: { totalRowCount: number };
|
||||
}>;
|
||||
@@ -74,5 +77,13 @@ export interface IWatchlistRepository {
|
||||
| null;
|
||||
auditHistory: WatchlistAuditEntry[];
|
||||
}>;
|
||||
findEntriesByIds(ids: string[]): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
isGlobal: boolean;
|
||||
organizationId: number | null;
|
||||
}>
|
||||
>;
|
||||
deleteEntry(id: string, userId: number): Promise<void>;
|
||||
bulkDeleteEntries(params: { ids: string[]; userId: number }): Promise<{ deleted: number }>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { WatchlistSource } from "@calcom/prisma/enums";
|
||||
import { WatchlistAction, WatchlistSource } from "@calcom/prisma/enums";
|
||||
|
||||
import type {
|
||||
IWatchlistRepository,
|
||||
@@ -14,17 +14,6 @@ export class WatchlistRepository implements IWatchlistRepository {
|
||||
constructor(private readonly prismaClient: PrismaClient) {}
|
||||
|
||||
async createEntry(params: CreateWatchlistInput): Promise<WatchlistEntry> {
|
||||
const existing = await this.checkExists({
|
||||
type: params.type,
|
||||
value: params.value,
|
||||
organizationId: params.organizationId,
|
||||
isGlobal: params.isGlobal,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error("Watchlist entry already exists for this organization");
|
||||
}
|
||||
|
||||
const watchlist = await this.prismaClient.$transaction(async (tx) => {
|
||||
const created = await tx.watchlist.create({
|
||||
data: {
|
||||
@@ -55,6 +44,21 @@ export class WatchlistRepository implements IWatchlistRepository {
|
||||
return watchlist;
|
||||
}
|
||||
|
||||
async createEntryIfNotExists(params: CreateWatchlistInput): Promise<WatchlistEntry> {
|
||||
const existing = await this.checkExists({
|
||||
type: params.type,
|
||||
value: params.value,
|
||||
organizationId: params.organizationId,
|
||||
isGlobal: params.isGlobal,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return this.createEntry(params);
|
||||
}
|
||||
|
||||
async checkExists(params: CheckWatchlistInput): Promise<WatchlistEntry | null> {
|
||||
if (!params.isGlobal && !params.organizationId) {
|
||||
throw new Error("Both isGlobal and organizationId are missing");
|
||||
@@ -87,12 +91,13 @@ export class WatchlistRepository implements IWatchlistRepository {
|
||||
return null;
|
||||
}
|
||||
|
||||
async findAllEntries(params: FindAllEntriesInput): Promise<{
|
||||
rows: (WatchlistEntry & { audits?: { changedByUserId: number | null }[] })[];
|
||||
async findAllEntriesWithLatestAudit(params: FindAllEntriesInput): Promise<{
|
||||
rows: (WatchlistEntry & { latestAudit: { changedByUserId: number | null } | null })[];
|
||||
meta: { totalRowCount: number };
|
||||
}> {
|
||||
const where = {
|
||||
organizationId: params.organizationId,
|
||||
isGlobal: params.isGlobal,
|
||||
...(params.searchTerm && {
|
||||
value: {
|
||||
contains: params.searchTerm,
|
||||
@@ -102,9 +107,12 @@ export class WatchlistRepository implements IWatchlistRepository {
|
||||
...(params.filters?.type && {
|
||||
type: params.filters.type,
|
||||
}),
|
||||
...(params.filters?.source && {
|
||||
source: params.filters.source,
|
||||
}),
|
||||
};
|
||||
|
||||
const [rows, totalRowCount] = await Promise.all([
|
||||
const [entries, totalRowCount] = await Promise.all([
|
||||
this.prismaClient.watchlist.findMany({
|
||||
where,
|
||||
take: params.limit,
|
||||
@@ -136,6 +144,12 @@ export class WatchlistRepository implements IWatchlistRepository {
|
||||
this.prismaClient.watchlist.count({ where }),
|
||||
]);
|
||||
|
||||
const rows = entries.map((entry) => ({
|
||||
...entry,
|
||||
latestAudit: entry.audits[0] ?? null,
|
||||
audits: undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
rows,
|
||||
meta: { totalRowCount },
|
||||
@@ -214,6 +228,23 @@ export class WatchlistRepository implements IWatchlistRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async findEntriesByIds(ids: string[]): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
isGlobal: boolean;
|
||||
organizationId: number | null;
|
||||
}>
|
||||
> {
|
||||
return this.prismaClient.watchlist.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: {
|
||||
id: true,
|
||||
isGlobal: true,
|
||||
organizationId: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEntry(id: string, userId: number): Promise<void> {
|
||||
const existing = await this.prismaClient.watchlist.findUnique({
|
||||
where: { id },
|
||||
@@ -247,4 +278,61 @@ export class WatchlistRepository implements IWatchlistRepository {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async bulkDeleteEntries(params: { ids: string[]; userId: number }): Promise<{ deleted: number }> {
|
||||
const entries = await this.prismaClient.watchlist.findMany({
|
||||
where: { id: { in: params.ids } },
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
value: true,
|
||||
description: true,
|
||||
action: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (entries.length === 0) {
|
||||
return { deleted: 0 };
|
||||
}
|
||||
|
||||
await this.prismaClient.$transaction(async (tx) => {
|
||||
await tx.watchlistAudit.createMany({
|
||||
data: entries.map((entry) => ({
|
||||
watchlistId: entry.id,
|
||||
type: entry.type,
|
||||
value: entry.value,
|
||||
description: entry.description,
|
||||
action: entry.action,
|
||||
changedByUserId: params.userId,
|
||||
})),
|
||||
});
|
||||
|
||||
await tx.watchlist.deleteMany({
|
||||
where: { id: { in: entries.map((e) => e.id) } },
|
||||
});
|
||||
});
|
||||
|
||||
return { deleted: entries.length };
|
||||
}
|
||||
|
||||
async createEntryFromReport(params: {
|
||||
type: CreateWatchlistInput["type"];
|
||||
value: string;
|
||||
organizationId: number | null;
|
||||
isGlobal: boolean;
|
||||
userId: number;
|
||||
description?: string;
|
||||
}): Promise<{ watchlistEntry: WatchlistEntry; value: string }> {
|
||||
const watchlistEntry = await this.createEntryIfNotExists({
|
||||
type: params.type,
|
||||
value: params.value,
|
||||
organizationId: params.organizationId,
|
||||
isGlobal: params.isGlobal,
|
||||
action: WatchlistAction.BLOCK,
|
||||
description: params.description,
|
||||
userId: params.userId,
|
||||
});
|
||||
|
||||
return { watchlistEntry, value: params.value };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
workspacePlatformUpdateServiceAccountSchema,
|
||||
workspacePlatformToggleEnabledSchema,
|
||||
} from "./workspacePlatform/schema";
|
||||
import { watchlistRouter } from "./watchlist/_router";
|
||||
|
||||
const NAMESPACE = "admin";
|
||||
|
||||
@@ -105,4 +106,5 @@ export const adminRouter = router({
|
||||
return handler(opts);
|
||||
}),
|
||||
}),
|
||||
watchlist: watchlistRouter,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { authedAdminProcedure } from "../../../../procedures/authedProcedure";
|
||||
import { router } from "../../../../trpc";
|
||||
import { ZAddToWatchlistInputSchema } from "./addToWatchlist.schema";
|
||||
import { ZBulkDeleteWatchlistEntriesInputSchema } from "./bulkDelete.schema";
|
||||
import { ZBulkDismissReportsInputSchema } from "./bulkDismiss.schema";
|
||||
import { ZCreateWatchlistEntryInputSchema } from "./create.schema";
|
||||
import { ZDeleteWatchlistEntryInputSchema } from "./delete.schema";
|
||||
import { ZDismissReportInputSchema } from "./dismissReport.schema";
|
||||
import { ZGetWatchlistEntryDetailsInputSchema } from "./getDetails.schema";
|
||||
import { ZListWatchlistEntriesInputSchema } from "./list.schema";
|
||||
import { ZListReportsInputSchema } from "./listReports.schema";
|
||||
|
||||
export const watchlistRouter = router({
|
||||
list: authedAdminProcedure.input(ZListWatchlistEntriesInputSchema).query(async (opts) => {
|
||||
const { listWatchlistEntriesHandler: handler } = await import("./list.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
create: authedAdminProcedure.input(ZCreateWatchlistEntryInputSchema).mutation(async (opts) => {
|
||||
const { createWatchlistEntryHandler: handler } = await import("./create.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
delete: authedAdminProcedure.input(ZDeleteWatchlistEntryInputSchema).mutation(async (opts) => {
|
||||
const { deleteWatchlistEntryHandler: handler } = await import("./delete.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
bulkDelete: authedAdminProcedure
|
||||
.input(ZBulkDeleteWatchlistEntriesInputSchema)
|
||||
.mutation(async (opts) => {
|
||||
const { bulkDeleteWatchlistEntriesHandler: handler } = await import("./bulkDelete.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
getDetails: authedAdminProcedure.input(ZGetWatchlistEntryDetailsInputSchema).query(async (opts) => {
|
||||
const { getWatchlistEntryDetailsHandler: handler } = await import("./getDetails.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
// Report management endpoints
|
||||
listReports: authedAdminProcedure.input(ZListReportsInputSchema).query(async (opts) => {
|
||||
const { listReportsHandler: handler } = await import("./listReports.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
dismissReport: authedAdminProcedure.input(ZDismissReportInputSchema).mutation(async (opts) => {
|
||||
const { dismissReportHandler: handler } = await import("./dismissReport.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
bulkDismiss: authedAdminProcedure.input(ZBulkDismissReportsInputSchema).mutation(async (opts) => {
|
||||
const { bulkDismissReportsHandler: handler } = await import("./bulkDismiss.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
addToWatchlist: authedAdminProcedure.input(ZAddToWatchlistInputSchema).mutation(async (opts) => {
|
||||
const { addToWatchlistHandler: handler } = await import("./addToWatchlist.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
pendingReportsCount: authedAdminProcedure.query(async () => {
|
||||
const { pendingReportsCountHandler: handler } = await import("./pendingReportsCount.handler");
|
||||
return handler();
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { getAdminWatchlistOperationsService } from "@calcom/features/di/watchlist/containers/watchlist";
|
||||
import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
|
||||
|
||||
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 service = getAdminWatchlistOperationsService();
|
||||
|
||||
try {
|
||||
return await service.addReportsToWatchlist({
|
||||
reportIds: input.reportIds,
|
||||
type: input.type,
|
||||
description: input.description,
|
||||
userId: user.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WatchlistError) {
|
||||
switch (error.code) {
|
||||
case WatchlistErrorCode.NOT_FOUND:
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: error.message,
|
||||
});
|
||||
case WatchlistErrorCode.UNAUTHORIZED:
|
||||
case WatchlistErrorCode.PERMISSION_DENIED:
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: error.message,
|
||||
});
|
||||
case WatchlistErrorCode.ALREADY_IN_WATCHLIST:
|
||||
case WatchlistErrorCode.INVALID_EMAIL:
|
||||
case WatchlistErrorCode.INVALID_DOMAIN:
|
||||
case WatchlistErrorCode.INVALID_IP:
|
||||
case WatchlistErrorCode.VALIDATION_ERROR:
|
||||
case WatchlistErrorCode.DUPLICATE_ENTRY:
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error.message,
|
||||
});
|
||||
default:
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { getAdminWatchlistOperationsService } from "@calcom/features/di/watchlist/containers/watchlist";
|
||||
import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../../types";
|
||||
import type { TBulkDeleteWatchlistEntriesInputSchema } from "./bulkDelete.schema";
|
||||
|
||||
type BulkDeleteWatchlistEntriesOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TBulkDeleteWatchlistEntriesInputSchema;
|
||||
};
|
||||
|
||||
export const bulkDeleteWatchlistEntriesHandler = async ({
|
||||
ctx,
|
||||
input,
|
||||
}: BulkDeleteWatchlistEntriesOptions) => {
|
||||
const { user } = ctx;
|
||||
const service = getAdminWatchlistOperationsService();
|
||||
|
||||
try {
|
||||
return await service.bulkDeleteWatchlistEntries({
|
||||
entryIds: input.ids,
|
||||
userId: user.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WatchlistError) {
|
||||
switch (error.code) {
|
||||
case WatchlistErrorCode.BULK_DELETE_PARTIAL_FAILURE:
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error.message,
|
||||
});
|
||||
default:
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to delete system blocklist entries",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZBulkDeleteWatchlistEntriesInputSchema = z.object({
|
||||
ids: z.array(z.string().uuid()).min(1, "At least one entry must be selected"),
|
||||
});
|
||||
|
||||
export type TBulkDeleteWatchlistEntriesInputSchema = z.infer<typeof ZBulkDeleteWatchlistEntriesInputSchema>;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { getAdminWatchlistOperationsService } from "@calcom/features/di/watchlist/containers/watchlist";
|
||||
import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../../types";
|
||||
import type { TBulkDismissReportsInputSchema } from "./bulkDismiss.schema";
|
||||
|
||||
type BulkDismissReportsOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TBulkDismissReportsInputSchema;
|
||||
};
|
||||
|
||||
export const bulkDismissReportsHandler = async ({ input }: BulkDismissReportsOptions) => {
|
||||
const service = getAdminWatchlistOperationsService();
|
||||
|
||||
try {
|
||||
return await service.bulkDismissReports({
|
||||
reportIds: input.reportIds,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WatchlistError) {
|
||||
switch (error.code) {
|
||||
case WatchlistErrorCode.BULK_DELETE_PARTIAL_FAILURE:
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error.message,
|
||||
});
|
||||
default:
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to dismiss reports",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZBulkDismissReportsInputSchema = z.object({
|
||||
reportIds: z.array(z.string().uuid()).min(1, "At least one report must be selected"),
|
||||
});
|
||||
|
||||
export type TBulkDismissReportsInputSchema = z.infer<typeof ZBulkDismissReportsInputSchema>;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { getAdminWatchlistOperationsService } from "@calcom/features/di/watchlist/containers/watchlist";
|
||||
import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../../types";
|
||||
import type { TCreateWatchlistEntryInputSchema } from "./create.schema";
|
||||
|
||||
type CreateWatchlistEntryOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TCreateWatchlistEntryInputSchema;
|
||||
};
|
||||
|
||||
export const createWatchlistEntryHandler = async ({ ctx, input }: CreateWatchlistEntryOptions) => {
|
||||
const { user } = ctx;
|
||||
const service = getAdminWatchlistOperationsService();
|
||||
|
||||
try {
|
||||
return await service.createWatchlistEntry({
|
||||
type: input.type,
|
||||
value: input.value,
|
||||
description: input.description,
|
||||
userId: user.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WatchlistError) {
|
||||
switch (error.code) {
|
||||
case WatchlistErrorCode.INVALID_EMAIL:
|
||||
case WatchlistErrorCode.INVALID_DOMAIN:
|
||||
case WatchlistErrorCode.DUPLICATE_ENTRY:
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error.message,
|
||||
});
|
||||
default:
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { WatchlistType } from "@calcom/prisma/enums";
|
||||
|
||||
export const ZCreateWatchlistEntryInputSchema = z.object({
|
||||
type: z.nativeEnum(WatchlistType),
|
||||
value: z.string().min(1).max(255),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TCreateWatchlistEntryInputSchema = z.infer<typeof ZCreateWatchlistEntryInputSchema>;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { getAdminWatchlistOperationsService } from "@calcom/features/di/watchlist/containers/watchlist";
|
||||
import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../../types";
|
||||
import type { TDeleteWatchlistEntryInputSchema } from "./delete.schema";
|
||||
|
||||
type DeleteWatchlistEntryOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TDeleteWatchlistEntryInputSchema;
|
||||
};
|
||||
|
||||
export const deleteWatchlistEntryHandler = async ({ ctx, input }: DeleteWatchlistEntryOptions) => {
|
||||
const { user } = ctx;
|
||||
const service = getAdminWatchlistOperationsService();
|
||||
|
||||
try {
|
||||
return await service.deleteWatchlistEntry({
|
||||
entryId: input.id,
|
||||
userId: user.id,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to delete system blocklist entry", { error });
|
||||
|
||||
if (error instanceof WatchlistError) {
|
||||
switch (error.code) {
|
||||
case WatchlistErrorCode.NOT_FOUND:
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: error.message,
|
||||
});
|
||||
case WatchlistErrorCode.PERMISSION_DENIED:
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: error.message,
|
||||
});
|
||||
default:
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to delete system blocklist entry",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZDeleteWatchlistEntryInputSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
|
||||
export type TDeleteWatchlistEntryInputSchema = z.infer<typeof ZDeleteWatchlistEntryInputSchema>;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { getAdminWatchlistOperationsService } from "@calcom/features/di/watchlist/containers/watchlist";
|
||||
import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../../types";
|
||||
import type { TDismissReportInputSchema } from "./dismissReport.schema";
|
||||
|
||||
type DismissReportOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TDismissReportInputSchema;
|
||||
};
|
||||
|
||||
export const dismissReportHandler = async ({ input }: DismissReportOptions) => {
|
||||
const service = getAdminWatchlistOperationsService();
|
||||
|
||||
try {
|
||||
return await service.dismissReport({
|
||||
reportId: input.reportId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WatchlistError) {
|
||||
switch (error.code) {
|
||||
case WatchlistErrorCode.NOT_FOUND:
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: error.message,
|
||||
});
|
||||
case WatchlistErrorCode.VALIDATION_ERROR:
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error.message,
|
||||
});
|
||||
default:
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to dismiss report",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZDismissReportInputSchema = z.object({
|
||||
reportId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export type TDismissReportInputSchema = z.infer<typeof ZDismissReportInputSchema>;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { getAdminWatchlistQueryService } from "@calcom/features/di/watchlist/containers/watchlist";
|
||||
import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../../types";
|
||||
import type { TGetWatchlistEntryDetailsInputSchema } from "./getDetails.schema";
|
||||
|
||||
type GetWatchlistEntryDetailsOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TGetWatchlistEntryDetailsInputSchema;
|
||||
};
|
||||
|
||||
export const getWatchlistEntryDetailsHandler = async ({ input }: GetWatchlistEntryDetailsOptions) => {
|
||||
const service = getAdminWatchlistQueryService();
|
||||
|
||||
try {
|
||||
return await service.getWatchlistEntryDetails({
|
||||
entryId: input.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WatchlistError) {
|
||||
switch (error.code) {
|
||||
case WatchlistErrorCode.NOT_FOUND:
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: error.message,
|
||||
});
|
||||
case WatchlistErrorCode.UNAUTHORIZED:
|
||||
case WatchlistErrorCode.PERMISSION_DENIED:
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: error.message,
|
||||
});
|
||||
default:
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to get blocklist entry details",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZGetWatchlistEntryDetailsInputSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
|
||||
export type TGetWatchlistEntryDetailsInputSchema = z.infer<typeof ZGetWatchlistEntryDetailsInputSchema>;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { getAdminWatchlistQueryService } from "@calcom/features/di/watchlist/containers/watchlist";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../../types";
|
||||
import type { TListWatchlistEntriesInputSchema } from "./list.schema";
|
||||
|
||||
type ListWatchlistEntriesOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TListWatchlistEntriesInputSchema;
|
||||
};
|
||||
|
||||
export const listWatchlistEntriesHandler = async ({ input }: ListWatchlistEntriesOptions) => {
|
||||
const service = getAdminWatchlistQueryService();
|
||||
|
||||
return await service.listWatchlistEntries({
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
searchTerm: input.searchTerm,
|
||||
filters: input.filters,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { WatchlistType, WatchlistSource } from "@calcom/prisma/enums";
|
||||
|
||||
export const ZListWatchlistEntriesInputSchema = z.object({
|
||||
limit: z.number().int().min(1).max(100).default(25),
|
||||
offset: z.number().int().min(0).default(0),
|
||||
searchTerm: z.string().optional(),
|
||||
filters: z
|
||||
.object({
|
||||
type: z.nativeEnum(WatchlistType).optional(),
|
||||
source: z.nativeEnum(WatchlistSource).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type TListWatchlistEntriesInputSchema = z.infer<typeof ZListWatchlistEntriesInputSchema>;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { getAdminWatchlistQueryService } from "@calcom/features/di/watchlist/containers/watchlist";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../../types";
|
||||
import type { TListReportsInputSchema } from "./listReports.schema";
|
||||
|
||||
type ListReportsOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TListReportsInputSchema;
|
||||
};
|
||||
|
||||
export const listReportsHandler = async ({ input }: ListReportsOptions) => {
|
||||
const service = getAdminWatchlistQueryService();
|
||||
|
||||
return await service.listBookingReports({
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
searchTerm: input.searchTerm,
|
||||
filters: input.filters,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { BookingReportReason, BookingReportStatus } from "@calcom/prisma/enums";
|
||||
|
||||
export const ZListReportsInputSchema = 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({
|
||||
status: z.array(z.nativeEnum(BookingReportStatus)).optional(),
|
||||
reason: z.array(z.nativeEnum(BookingReportReason)).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type TListReportsInputSchema = z.infer<typeof ZListReportsInputSchema>;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { getAdminWatchlistQueryService } from "@calcom/features/di/watchlist/containers/watchlist";
|
||||
|
||||
export const pendingReportsCountHandler = async () => {
|
||||
const service = getAdminWatchlistQueryService();
|
||||
return await service.getPendingReportsCount();
|
||||
};
|
||||
@@ -174,25 +174,25 @@ export const viewerOrganizationsRouter = router({
|
||||
listWatchlistEntries: authedOrgAdminProcedure
|
||||
.input(ZListWatchlistEntriesInputSchema)
|
||||
.query(async (opts) => {
|
||||
const { default: handler } = await import("./listWatchlistEntries.handler");
|
||||
const { listWatchlistEntriesHandler: handler } = await import("./listWatchlistEntries.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
createWatchlistEntry: authedOrgAdminProcedure
|
||||
.input(ZCreateWatchlistEntryInputSchema)
|
||||
.mutation(async (opts) => {
|
||||
const { default: handler } = await import("./createWatchlistEntry.handler");
|
||||
const { createWatchlistEntryHandler: handler } = await import("./createWatchlistEntry.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
deleteWatchlistEntry: authedOrgAdminProcedure
|
||||
.input(ZDeleteWatchlistEntryInputSchema)
|
||||
.mutation(async (opts) => {
|
||||
const { default: handler } = await import("./deleteWatchlistEntry.handler");
|
||||
const { deleteWatchlistEntryHandler: handler } = await import("./deleteWatchlistEntry.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
getWatchlistEntryDetails: authedOrgAdminProcedure
|
||||
.input(ZGetWatchlistEntryDetailsInputSchema)
|
||||
.query(async (opts) => {
|
||||
const { default: handler } = await import("./getWatchlistEntryDetails.handler");
|
||||
const { getWatchlistEntryDetailsHandler: handler } = await import("./getWatchlistEntryDetails.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
listBookingReports: authedOrgAdminProcedure
|
||||
@@ -204,7 +204,7 @@ export const viewerOrganizationsRouter = router({
|
||||
addToWatchlist: authedOrgAdminProcedure
|
||||
.input(ZAddToWatchlistInputSchema)
|
||||
.mutation(async (opts) => {
|
||||
const { default: handler } = await import("./addToWatchlist.handler");
|
||||
const { addToWatchlistHandler: handler } = await import("./addToWatchlist.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
dismissBookingReport: authedOrgAdminProcedure
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
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 { WatchlistErrors } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
|
||||
import { WatchlistType } 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");
|
||||
vi.mock("@calcom/features/di/watchlist/containers/watchlist");
|
||||
|
||||
describe("addToWatchlistHandler (Organization)", () => {
|
||||
const mockUser = {
|
||||
@@ -26,75 +15,16 @@ describe("addToWatchlistHandler (Organization)", () => {
|
||||
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 mockService = {
|
||||
addReportsToWatchlist: vi.fn(),
|
||||
};
|
||||
|
||||
const mockReportRepo = {
|
||||
findReportsByIds: vi.fn(),
|
||||
linkWatchlistToReport: vi.fn(),
|
||||
updateReportStatus: vi.fn(),
|
||||
};
|
||||
|
||||
const mockWatchlistRepo = {
|
||||
checkExists: vi.fn(),
|
||||
createEntry: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
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>
|
||||
const { getOrganizationWatchlistOperationsService } = await import(
|
||||
"@calcom/features/di/watchlist/containers/watchlist"
|
||||
);
|
||||
vi.mocked(getOrganizationWatchlistOperationsService).mockReturnValue(mockService as never);
|
||||
});
|
||||
|
||||
describe("access control", () => {
|
||||
@@ -112,11 +42,34 @@ describe("addToWatchlistHandler (Organization)", () => {
|
||||
message: "You must be part of an organization to add to watchlist",
|
||||
});
|
||||
|
||||
expect(mockPermissionCheckService.checkPermission).not.toHaveBeenCalled();
|
||||
expect(mockService.addReportsToWatchlist).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("error mapping", () => {
|
||||
it("should map NOT_FOUND error to NOT_FOUND", async () => {
|
||||
mockService.addReportsToWatchlist.mockRejectedValue(
|
||||
WatchlistErrors.notFound("Booking report(s) not found: report-1")
|
||||
);
|
||||
|
||||
await expect(
|
||||
addToWatchlistHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
reportIds: ["report-1"],
|
||||
type: WatchlistType.EMAIL,
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Booking report(s) not found: report-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw UNAUTHORIZED when user lacks permission", async () => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
|
||||
it("should map PERMISSION_DENIED error to UNAUTHORIZED", async () => {
|
||||
mockService.addReportsToWatchlist.mockRejectedValue(
|
||||
WatchlistErrors.permissionDenied("You are not authorized to add entries to the watchlist")
|
||||
);
|
||||
|
||||
await expect(
|
||||
addToWatchlistHandler({
|
||||
@@ -130,89 +83,18 @@ describe("addToWatchlistHandler (Organization)", () => {
|
||||
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([]);
|
||||
it("should map ALREADY_IN_WATCHLIST error to BAD_REQUEST", async () => {
|
||||
mockService.addReportsToWatchlist.mockRejectedValue(
|
||||
WatchlistErrors.alreadyInWatchlist("All selected bookers are already in the watchlist")
|
||||
);
|
||||
|
||||
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"],
|
||||
reportIds: ["report-1"],
|
||||
type: WatchlistType.EMAIL,
|
||||
},
|
||||
})
|
||||
@@ -221,160 +103,48 @@ describe("addToWatchlistHandler (Organization)", () => {
|
||||
message: "All selected bookers are already in the watchlist",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("successful addition", () => {
|
||||
beforeEach(() => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
it("should re-throw unknown service errors", async () => {
|
||||
const unknownError = new Error("Database connection failed");
|
||||
mockService.addReportsToWatchlist.mockRejectedValue(unknownError);
|
||||
|
||||
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,
|
||||
});
|
||||
await expect(
|
||||
addToWatchlistHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
reportIds: ["report-1"],
|
||||
type: WatchlistType.EMAIL,
|
||||
},
|
||||
})
|
||||
).rejects.toThrow("Database connection failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("organization scoping", () => {
|
||||
beforeEach(() => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
describe("successful delegation", () => {
|
||||
it("should delegate to service with correct parameters", async () => {
|
||||
mockService.addReportsToWatchlist.mockResolvedValue({
|
||||
success: true,
|
||||
addedCount: 2,
|
||||
message: "Successfully added 2 entries to watchlist",
|
||||
});
|
||||
|
||||
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({
|
||||
const result = await addToWatchlistHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
reportIds: ["report-1"],
|
||||
reportIds: ["report-1", "report-2"],
|
||||
type: WatchlistType.EMAIL,
|
||||
description: "Spam users",
|
||||
},
|
||||
});
|
||||
|
||||
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({
|
||||
expect(mockService.addReportsToWatchlist).toHaveBeenCalledWith({
|
||||
reportIds: ["report-1", "report-2"],
|
||||
type: WatchlistType.EMAIL,
|
||||
value: "spammer@example.com",
|
||||
organizationId: 100,
|
||||
description: "Spam users",
|
||||
userId: 1,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.addedCount).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
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 { getOrganizationWatchlistOperationsService } from "@calcom/features/di/watchlist/containers/watchlist";
|
||||
import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
@@ -31,108 +24,45 @@ export const addToWatchlistHandler = async ({ ctx, input }: AddToWatchlistOption
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
const service = getOrganizationWatchlistOperationsService(organizationId);
|
||||
|
||||
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 })),
|
||||
};
|
||||
return await service.addReportsToWatchlist({
|
||||
reportIds: input.reportIds,
|
||||
type: input.type,
|
||||
description: input.description,
|
||||
userId: user.id,
|
||||
});
|
||||
} 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",
|
||||
});
|
||||
if (error instanceof WatchlistError) {
|
||||
switch (error.code) {
|
||||
case WatchlistErrorCode.UNAUTHORIZED:
|
||||
case WatchlistErrorCode.PERMISSION_DENIED:
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: error.message,
|
||||
});
|
||||
case WatchlistErrorCode.NOT_FOUND:
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: error.message,
|
||||
});
|
||||
case WatchlistErrorCode.ALREADY_IN_WATCHLIST:
|
||||
case WatchlistErrorCode.INVALID_EMAIL:
|
||||
case WatchlistErrorCode.INVALID_DOMAIN:
|
||||
case WatchlistErrorCode.DUPLICATE_ENTRY:
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error.message,
|
||||
});
|
||||
default:
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default addToWatchlistHandler;
|
||||
|
||||
+77
-193
@@ -1,16 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
|
||||
import { WatchlistType, WatchlistAction, MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { WatchlistErrors } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
|
||||
import { WatchlistType } from "@calcom/prisma/enums";
|
||||
|
||||
import { createWatchlistEntryHandler } from "./createWatchlistEntry.handler";
|
||||
|
||||
vi.mock("@calcom/features/pbac/services/permission-check.service");
|
||||
vi.mock("@calcom/lib/server/repository/watchlist.repository");
|
||||
vi.mock("@calcom/features/di/watchlist/containers/watchlist");
|
||||
|
||||
describe("createWatchlistEntryHandler", () => {
|
||||
const mockUser = {
|
||||
@@ -20,25 +15,23 @@ describe("createWatchlistEntryHandler", () => {
|
||||
profile: null,
|
||||
};
|
||||
|
||||
const mockPermissionCheckService = {
|
||||
checkPermission: vi.fn(),
|
||||
const mockService = {
|
||||
createWatchlistEntry: vi.fn(),
|
||||
};
|
||||
|
||||
const mockWatchlistRepo = {
|
||||
createEntry: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(PermissionCheckService).mockImplementation(() => mockPermissionCheckService as any);
|
||||
vi.mocked(WatchlistRepository).mockImplementation(() => mockWatchlistRepo as any);
|
||||
const { getOrganizationWatchlistOperationsService } = await import(
|
||||
"@calcom/features/di/watchlist/containers/watchlist"
|
||||
);
|
||||
vi.mocked(getOrganizationWatchlistOperationsService).mockReturnValue(mockService as never);
|
||||
});
|
||||
|
||||
describe("access control", () => {
|
||||
it("should throw FORBIDDEN when user is not part of an organization", async () => {
|
||||
await expect(
|
||||
createWatchlistEntryHandler({
|
||||
ctx: { user: { ...(mockUser as any), organizationId: undefined, profile: null } },
|
||||
ctx: { user: { ...mockUser, organizationId: undefined, profile: null } as never },
|
||||
input: {
|
||||
type: WatchlistType.EMAIL,
|
||||
value: "spam@example.com",
|
||||
@@ -49,58 +42,19 @@ describe("createWatchlistEntryHandler", () => {
|
||||
message: "You must be part of an organization to manage blocklist",
|
||||
});
|
||||
|
||||
expect(mockPermissionCheckService.checkPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw UNAUTHORIZED when user lacks permission", async () => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
createWatchlistEntryHandler({
|
||||
ctx: { user: mockUser as any },
|
||||
input: {
|
||||
type: WatchlistType.EMAIL,
|
||||
value: "spam@example.com",
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to create blocklist entries",
|
||||
});
|
||||
|
||||
expect(mockWatchlistRepo.createEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should check permission with correct parameters", async () => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-1" });
|
||||
|
||||
await createWatchlistEntryHandler({
|
||||
ctx: { user: mockUser as any },
|
||||
input: {
|
||||
type: WatchlistType.EMAIL,
|
||||
value: "test@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
|
||||
userId: 1,
|
||||
teamId: 100,
|
||||
permission: "watchlist.create",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
expect(mockService.createWatchlistEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation", () => {
|
||||
beforeEach(() => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
describe("error mapping", () => {
|
||||
it("should map INVALID_EMAIL error to BAD_REQUEST", async () => {
|
||||
mockService.createWatchlistEntry.mockRejectedValue(
|
||||
WatchlistErrors.invalidEmail("Invalid email address format")
|
||||
);
|
||||
|
||||
it("should throw BAD_REQUEST for invalid email format", async () => {
|
||||
await expect(
|
||||
createWatchlistEntryHandler({
|
||||
ctx: { user: mockUser as any },
|
||||
ctx: { user: mockUser as never },
|
||||
input: {
|
||||
type: WatchlistType.EMAIL,
|
||||
value: "invalid-email",
|
||||
@@ -110,194 +64,124 @@ describe("createWatchlistEntryHandler", () => {
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid email address format",
|
||||
});
|
||||
|
||||
expect(mockWatchlistRepo.createEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw BAD_REQUEST for email missing @ symbol", async () => {
|
||||
it("should map INVALID_DOMAIN error to BAD_REQUEST", async () => {
|
||||
mockService.createWatchlistEntry.mockRejectedValue(
|
||||
WatchlistErrors.invalidDomain("Invalid domain format (e.g., example.com)")
|
||||
);
|
||||
|
||||
await expect(
|
||||
createWatchlistEntryHandler({
|
||||
ctx: { user: mockUser as any },
|
||||
input: {
|
||||
type: WatchlistType.EMAIL,
|
||||
value: "notanemail.com",
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid email address format",
|
||||
});
|
||||
});
|
||||
|
||||
it("should accept valid domain format without @", async () => {
|
||||
mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-1" });
|
||||
|
||||
const result = await createWatchlistEntryHandler({
|
||||
ctx: { user: mockUser as any },
|
||||
input: {
|
||||
type: WatchlistType.DOMAIN,
|
||||
value: "example.com",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockWatchlistRepo.createEntry).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw BAD_REQUEST for domain with invalid format", async () => {
|
||||
await expect(
|
||||
createWatchlistEntryHandler({
|
||||
ctx: { user: mockUser as any },
|
||||
ctx: { user: mockUser as never },
|
||||
input: {
|
||||
type: WatchlistType.DOMAIN,
|
||||
value: "invalid..domain",
|
||||
},
|
||||
})
|
||||
).rejects.toStrictEqual(
|
||||
new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid domain format (e.g., example.com)",
|
||||
})
|
||||
);
|
||||
).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid domain format (e.g., example.com)",
|
||||
});
|
||||
});
|
||||
|
||||
it("should accept valid email format", async () => {
|
||||
mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-1" });
|
||||
it("should map PERMISSION_DENIED error to UNAUTHORIZED", async () => {
|
||||
mockService.createWatchlistEntry.mockRejectedValue(
|
||||
WatchlistErrors.permissionDenied("You are not authorized to create blocklist entries")
|
||||
);
|
||||
|
||||
const result = await createWatchlistEntryHandler({
|
||||
ctx: { user: mockUser as any },
|
||||
input: {
|
||||
type: WatchlistType.EMAIL,
|
||||
value: "valid@example.com",
|
||||
},
|
||||
await expect(
|
||||
createWatchlistEntryHandler({
|
||||
ctx: { user: mockUser as never },
|
||||
input: {
|
||||
type: WatchlistType.EMAIL,
|
||||
value: "spam@example.com",
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to create blocklist entries",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockWatchlistRepo.createEntry).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should accept valid domain format and normalize it", async () => {
|
||||
mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-1" });
|
||||
it("should re-throw unknown service errors", async () => {
|
||||
const unknownError = new Error("Database connection failed");
|
||||
mockService.createWatchlistEntry.mockRejectedValue(unknownError);
|
||||
|
||||
const result = await createWatchlistEntryHandler({
|
||||
ctx: { user: mockUser as any },
|
||||
input: {
|
||||
type: WatchlistType.DOMAIN,
|
||||
value: "example.com",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
value: "example.com",
|
||||
await expect(
|
||||
createWatchlistEntryHandler({
|
||||
ctx: { user: mockUser as never },
|
||||
input: {
|
||||
type: WatchlistType.EMAIL,
|
||||
value: "spam@example.com",
|
||||
},
|
||||
})
|
||||
);
|
||||
).rejects.toThrow("Database connection failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("successful creation", () => {
|
||||
beforeEach(() => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should create watchlist entry for EMAIL type", async () => {
|
||||
describe("successful delegation", () => {
|
||||
it("should delegate to service with correct parameters for EMAIL", async () => {
|
||||
const mockEntry = {
|
||||
id: "watchlist-1",
|
||||
type: WatchlistType.EMAIL,
|
||||
value: "spam@example.com",
|
||||
organizationId: 100,
|
||||
action: WatchlistAction.BLOCK,
|
||||
};
|
||||
mockWatchlistRepo.createEntry.mockResolvedValue(mockEntry);
|
||||
|
||||
mockService.createWatchlistEntry.mockResolvedValue({
|
||||
success: true,
|
||||
entry: mockEntry,
|
||||
});
|
||||
|
||||
const result = await createWatchlistEntryHandler({
|
||||
ctx: { user: mockUser as any },
|
||||
ctx: { user: mockUser as never },
|
||||
input: {
|
||||
type: WatchlistType.EMAIL,
|
||||
value: "spam@example.com",
|
||||
description: "Known spammer",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.entry).toEqual(mockEntry);
|
||||
expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith({
|
||||
expect(mockService.createWatchlistEntry).toHaveBeenCalledWith({
|
||||
type: WatchlistType.EMAIL,
|
||||
value: "spam@example.com",
|
||||
organizationId: 100,
|
||||
action: WatchlistAction.BLOCK,
|
||||
description: undefined,
|
||||
description: "Known spammer",
|
||||
userId: 1,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.entry).toEqual(mockEntry);
|
||||
});
|
||||
|
||||
it("should create watchlist entry for DOMAIN type and normalize it", async () => {
|
||||
it("should delegate to service with correct parameters for DOMAIN", async () => {
|
||||
const mockEntry = {
|
||||
id: "watchlist-2",
|
||||
type: WatchlistType.DOMAIN,
|
||||
value: "spammer.com",
|
||||
organizationId: 100,
|
||||
action: WatchlistAction.BLOCK,
|
||||
};
|
||||
mockWatchlistRepo.createEntry.mockResolvedValue(mockEntry);
|
||||
|
||||
mockService.createWatchlistEntry.mockResolvedValue({
|
||||
success: true,
|
||||
entry: mockEntry,
|
||||
});
|
||||
|
||||
const result = await createWatchlistEntryHandler({
|
||||
ctx: { user: mockUser as any },
|
||||
ctx: { user: mockUser as never },
|
||||
input: {
|
||||
type: WatchlistType.DOMAIN,
|
||||
value: "spammer.com",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.entry).toEqual(mockEntry);
|
||||
expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith({
|
||||
expect(mockService.createWatchlistEntry).toHaveBeenCalledWith({
|
||||
type: WatchlistType.DOMAIN,
|
||||
value: "spammer.com",
|
||||
organizationId: 100,
|
||||
action: WatchlistAction.BLOCK,
|
||||
description: undefined,
|
||||
userId: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("should convert value to lowercase", async () => {
|
||||
mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-4" });
|
||||
|
||||
await createWatchlistEntryHandler({
|
||||
ctx: { user: mockUser as any },
|
||||
input: {
|
||||
type: WatchlistType.EMAIL,
|
||||
value: "SPAM@EXAMPLE.COM",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith({
|
||||
type: WatchlistType.EMAIL,
|
||||
value: "spam@example.com",
|
||||
organizationId: 100,
|
||||
action: WatchlistAction.BLOCK,
|
||||
description: undefined,
|
||||
userId: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("should always set action to BLOCK", async () => {
|
||||
mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-5" });
|
||||
|
||||
await createWatchlistEntryHandler({
|
||||
ctx: { user: mockUser as any },
|
||||
input: {
|
||||
type: WatchlistType.EMAIL,
|
||||
value: "test@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: WatchlistAction.BLOCK,
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.entry).toEqual(mockEntry);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { domainRegex, emailRegex } from "@calcom/lib/emailSchema";
|
||||
import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole, WatchlistAction } from "@calcom/prisma/enums";
|
||||
import { getOrganizationWatchlistOperationsService } from "@calcom/features/di/watchlist/containers/watchlist";
|
||||
import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
@@ -27,60 +25,40 @@ export const createWatchlistEntryHandler = async ({ ctx, input }: CreateWatchlis
|
||||
});
|
||||
}
|
||||
|
||||
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 create blocklist entries",
|
||||
});
|
||||
}
|
||||
|
||||
const watchlistRepo = new WatchlistRepository(prisma);
|
||||
|
||||
if (input.type === "EMAIL" && !emailRegex.test(input.value)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid email address format",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.type === "DOMAIN" && !domainRegex.test(input.value)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid domain format (e.g., example.com)",
|
||||
});
|
||||
}
|
||||
const service = getOrganizationWatchlistOperationsService(organizationId);
|
||||
|
||||
try {
|
||||
const entry = await watchlistRepo.createEntry({
|
||||
return await service.createWatchlistEntry({
|
||||
type: input.type,
|
||||
value: input.value.toLowerCase(),
|
||||
organizationId,
|
||||
action: WatchlistAction.BLOCK,
|
||||
value: input.value,
|
||||
description: input.description,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
entry,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("already exists")) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This entry already exists in the blocklist for your organization",
|
||||
});
|
||||
logger.error("Failed to create blocklist entry", { error });
|
||||
if (error instanceof WatchlistError) {
|
||||
switch (error.code) {
|
||||
case WatchlistErrorCode.UNAUTHORIZED:
|
||||
case WatchlistErrorCode.PERMISSION_DENIED:
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: error.message,
|
||||
});
|
||||
case WatchlistErrorCode.INVALID_EMAIL:
|
||||
case WatchlistErrorCode.INVALID_DOMAIN:
|
||||
case WatchlistErrorCode.DUPLICATE_ENTRY:
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error.message,
|
||||
});
|
||||
default:
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default createWatchlistEntryHandler;
|
||||
|
||||
+69
-86
@@ -1,12 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
|
||||
import { MembershipRole, WatchlistType, WatchlistAction } from "@calcom/prisma/enums";
|
||||
import { WatchlistErrors } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
|
||||
import { WatchlistType, WatchlistAction } from "@calcom/prisma/enums";
|
||||
|
||||
import { deleteWatchlistEntryHandler } from "./deleteWatchlistEntry.handler";
|
||||
|
||||
vi.mock("@calcom/features/pbac/services/permission-check.service");
|
||||
vi.mock("@calcom/features/di/watchlist/containers/watchlist");
|
||||
vi.mock("@calcom/lib/server/repository/watchlist.repository");
|
||||
|
||||
describe("deleteWatchlistEntryHandler", () => {
|
||||
@@ -28,19 +27,23 @@ describe("deleteWatchlistEntryHandler", () => {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockPermissionCheckService = {
|
||||
checkPermission: vi.fn(),
|
||||
const mockService = {
|
||||
deleteWatchlistEntry: vi.fn(),
|
||||
};
|
||||
|
||||
const mockWatchlistRepo = {
|
||||
findEntryWithAuditAndReports: vi.fn(),
|
||||
deleteEntry: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(PermissionCheckService).mockImplementation(() => mockPermissionCheckService as any);
|
||||
vi.mocked(WatchlistRepository).mockImplementation(() => mockWatchlistRepo as any);
|
||||
const { getOrganizationWatchlistOperationsService } = await import(
|
||||
"@calcom/features/di/watchlist/containers/watchlist"
|
||||
);
|
||||
const { WatchlistRepository } = await import("@calcom/lib/server/repository/watchlist.repository");
|
||||
|
||||
vi.mocked(getOrganizationWatchlistOperationsService).mockReturnValue(mockService as never);
|
||||
vi.mocked(WatchlistRepository).mockImplementation(() => mockWatchlistRepo as never);
|
||||
});
|
||||
|
||||
describe("access control", () => {
|
||||
@@ -57,56 +60,11 @@ describe("deleteWatchlistEntryHandler", () => {
|
||||
message: "You must be part of an organization to manage blocklist",
|
||||
});
|
||||
|
||||
expect(mockPermissionCheckService.checkPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw UNAUTHORIZED when user lacks permission", async () => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
deleteWatchlistEntryHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
id: "entry-123",
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to delete blocklist entries",
|
||||
});
|
||||
|
||||
expect(mockWatchlistRepo.findEntryWithAuditAndReports).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should check permission with correct parameters", async () => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: [],
|
||||
});
|
||||
mockWatchlistRepo.deleteEntry.mockResolvedValue(undefined);
|
||||
|
||||
await deleteWatchlistEntryHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
id: "entry-123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
|
||||
userId: 1,
|
||||
teamId: 100,
|
||||
permission: "watchlist.delete",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
expect(mockService.deleteWatchlistEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation", () => {
|
||||
beforeEach(() => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe("validation in handler", () => {
|
||||
it("should throw NOT_FOUND when entry does not exist", async () => {
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: null,
|
||||
@@ -124,8 +82,6 @@ describe("deleteWatchlistEntryHandler", () => {
|
||||
code: "NOT_FOUND",
|
||||
message: "Blocklist entry not found",
|
||||
});
|
||||
|
||||
expect(mockWatchlistRepo.deleteEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw FORBIDDEN when entry belongs to different organization", async () => {
|
||||
@@ -145,22 +101,63 @@ describe("deleteWatchlistEntryHandler", () => {
|
||||
code: "FORBIDDEN",
|
||||
message: "You can only delete blocklist entries from your organization",
|
||||
});
|
||||
|
||||
expect(mockWatchlistRepo.deleteEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("successful deletion", () => {
|
||||
beforeEach(() => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should successfully delete entry when authorized", async () => {
|
||||
describe("error mapping from service", () => {
|
||||
it("should map PERMISSION_DENIED error to UNAUTHORIZED", async () => {
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: [],
|
||||
});
|
||||
mockWatchlistRepo.deleteEntry.mockResolvedValue(undefined);
|
||||
mockService.deleteWatchlistEntry.mockRejectedValue(
|
||||
WatchlistErrors.permissionDenied("You are not authorized to delete blocklist entries")
|
||||
);
|
||||
|
||||
await expect(
|
||||
deleteWatchlistEntryHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
id: "entry-123",
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to delete blocklist entries",
|
||||
});
|
||||
});
|
||||
|
||||
it("should map unknown service errors to INTERNAL_SERVER_ERROR", async () => {
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: [],
|
||||
});
|
||||
mockService.deleteWatchlistEntry.mockRejectedValue(new Error("Database connection failed"));
|
||||
|
||||
await expect(
|
||||
deleteWatchlistEntryHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
id: "entry-123",
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to delete blocklist entry",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("successful delegation", () => {
|
||||
it("should delegate to service with correct parameters", async () => {
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: [],
|
||||
});
|
||||
mockService.deleteWatchlistEntry.mockResolvedValue({
|
||||
success: true,
|
||||
message: "Blocklist entry deleted successfully",
|
||||
});
|
||||
|
||||
const result = await deleteWatchlistEntryHandler({
|
||||
ctx: { user: mockUser },
|
||||
@@ -169,26 +166,12 @@ describe("deleteWatchlistEntryHandler", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockService.deleteWatchlistEntry).toHaveBeenCalledWith({
|
||||
entryId: "entry-123",
|
||||
userId: 1,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe("Blocklist entry deleted successfully");
|
||||
expect(mockWatchlistRepo.deleteEntry).toHaveBeenCalledWith("entry-123", 1);
|
||||
});
|
||||
|
||||
it("should pass correct userId to deleteEntry", async () => {
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: [],
|
||||
});
|
||||
mockWatchlistRepo.deleteEntry.mockResolvedValue(undefined);
|
||||
|
||||
await deleteWatchlistEntryHandler({
|
||||
ctx: { user: { ...mockUser, id: 42 } },
|
||||
input: {
|
||||
id: "entry-123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockWatchlistRepo.deleteEntry).toHaveBeenCalledWith("entry-123", 42);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { getOrganizationWatchlistOperationsService } from "@calcom/features/di/watchlist/containers/watchlist";
|
||||
import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
|
||||
import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
@@ -26,23 +26,7 @@ export const deleteWatchlistEntryHandler = async ({ ctx, input }: DeleteWatchlis
|
||||
});
|
||||
}
|
||||
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
const hasPermission = await permissionCheckService.checkPermission({
|
||||
userId: user.id,
|
||||
teamId: organizationId,
|
||||
permission: "watchlist.delete",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to delete blocklist entries",
|
||||
});
|
||||
}
|
||||
|
||||
const watchlistRepo = new WatchlistRepository(prisma);
|
||||
|
||||
const { entry } = await watchlistRepo.findEntryWithAuditAndReports(input.id);
|
||||
|
||||
if (!entry) {
|
||||
@@ -59,19 +43,33 @@ export const deleteWatchlistEntryHandler = async ({ ctx, input }: DeleteWatchlis
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await watchlistRepo.deleteEntry(input.id, user.id);
|
||||
const service = getOrganizationWatchlistOperationsService(organizationId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Blocklist entry deleted successfully",
|
||||
};
|
||||
try {
|
||||
return await service.deleteWatchlistEntry({
|
||||
entryId: input.id,
|
||||
userId: user.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WatchlistError) {
|
||||
switch (error.code) {
|
||||
case WatchlistErrorCode.UNAUTHORIZED:
|
||||
case WatchlistErrorCode.PERMISSION_DENIED:
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: error.message,
|
||||
});
|
||||
default:
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to delete blocklist entry",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default deleteWatchlistEntryHandler;
|
||||
|
||||
+94
-276
@@ -1,15 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
|
||||
import { MembershipRole, WatchlistType, WatchlistAction } from "@calcom/prisma/enums";
|
||||
import { WatchlistErrors } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
|
||||
import { WatchlistType, WatchlistAction } from "@calcom/prisma/enums";
|
||||
|
||||
import { getWatchlistEntryDetailsHandler } from "./getWatchlistEntryDetails.handler";
|
||||
|
||||
vi.mock("@calcom/features/pbac/services/permission-check.service");
|
||||
vi.mock("@calcom/features/users/repositories/UserRepository");
|
||||
vi.mock("@calcom/lib/server/repository/watchlist.repository");
|
||||
vi.mock("@calcom/features/di/watchlist/containers/watchlist");
|
||||
|
||||
describe("getWatchlistEntryDetailsHandler", () => {
|
||||
const mockUser = {
|
||||
@@ -39,40 +35,20 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
||||
timestamp: new Date("2025-01-01"),
|
||||
previousValue: null,
|
||||
newValue: "spam@example.com",
|
||||
},
|
||||
{
|
||||
id: "audit-2",
|
||||
watchlistId: "entry-123",
|
||||
action: "UPDATED" as const,
|
||||
changedByUserId: 2,
|
||||
timestamp: new Date("2025-01-02"),
|
||||
previousValue: null,
|
||||
newValue: "Known spammer",
|
||||
changedByUser: { id: 1, name: "Admin", email: "admin@example.com" },
|
||||
},
|
||||
];
|
||||
|
||||
const mockUsers = [
|
||||
{ id: 1, name: "Admin", email: "admin@example.com" },
|
||||
{ id: 2, name: "Editor", email: "editor@example.com" },
|
||||
];
|
||||
|
||||
const mockPermissionCheckService = {
|
||||
checkPermission: vi.fn(),
|
||||
const mockService = {
|
||||
getWatchlistEntryDetails: vi.fn(),
|
||||
};
|
||||
|
||||
const mockWatchlistRepo = {
|
||||
findEntryWithAuditAndReports: vi.fn(),
|
||||
};
|
||||
|
||||
const mockUserRepo = {
|
||||
findUsersByIds: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(PermissionCheckService).mockImplementation(() => mockPermissionCheckService as any);
|
||||
vi.mocked(WatchlistRepository).mockImplementation(() => mockWatchlistRepo as any);
|
||||
vi.mocked(UserRepository).mockImplementation(() => mockUserRepo as any);
|
||||
const { getOrganizationWatchlistQueryService } = await import(
|
||||
"@calcom/features/di/watchlist/containers/watchlist"
|
||||
);
|
||||
vi.mocked(getOrganizationWatchlistQueryService).mockReturnValue(mockService as never);
|
||||
});
|
||||
|
||||
describe("access control", () => {
|
||||
@@ -89,12 +65,89 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
||||
message: "You must be part of an organization to view blocklist",
|
||||
});
|
||||
|
||||
expect(mockPermissionCheckService.checkPermission).not.toHaveBeenCalled();
|
||||
expect(mockService.getWatchlistEntryDetails).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("error mapping", () => {
|
||||
it("should map NOT_FOUND error to NOT_FOUND", async () => {
|
||||
mockService.getWatchlistEntryDetails.mockRejectedValue(
|
||||
WatchlistErrors.notFound("Blocklist entry not found")
|
||||
);
|
||||
|
||||
await expect(
|
||||
getWatchlistEntryDetailsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
id: "non-existent-id",
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Blocklist entry not found",
|
||||
});
|
||||
});
|
||||
|
||||
it("should map PERMISSION_DENIED error to FORBIDDEN", async () => {
|
||||
mockService.getWatchlistEntryDetails.mockRejectedValue(
|
||||
WatchlistErrors.permissionDenied("You are not authorized to view blocklist entries")
|
||||
);
|
||||
|
||||
await expect(
|
||||
getWatchlistEntryDetailsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
id: "entry-123",
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not authorized to view blocklist entries",
|
||||
});
|
||||
});
|
||||
|
||||
it("should map unknown service errors to INTERNAL_SERVER_ERROR", async () => {
|
||||
mockService.getWatchlistEntryDetails.mockRejectedValue(new Error("Database connection failed"));
|
||||
|
||||
await expect(
|
||||
getWatchlistEntryDetailsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
id: "entry-123",
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to get blocklist entry details",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("successful delegation", () => {
|
||||
it("should delegate to service with correct parameters", async () => {
|
||||
mockService.getWatchlistEntryDetails.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: mockAuditHistory,
|
||||
});
|
||||
|
||||
const result = await getWatchlistEntryDetailsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
id: "entry-123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockService.getWatchlistEntryDetails).toHaveBeenCalledWith({
|
||||
organizationId: 100,
|
||||
userId: 1,
|
||||
entryId: "entry-123",
|
||||
});
|
||||
expect(result.entry).toEqual(mockEntry);
|
||||
expect(result.auditHistory).toEqual(mockAuditHistory);
|
||||
});
|
||||
|
||||
it("should use profile.organizationId when available", async () => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
mockService.getWatchlistEntryDetails.mockResolvedValue({
|
||||
entry: { ...mockEntry, organizationId: 200 },
|
||||
auditHistory: [],
|
||||
});
|
||||
@@ -112,246 +165,11 @@ describe("getWatchlistEntryDetailsHandler", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
|
||||
expect(mockService.getWatchlistEntryDetails).toHaveBeenCalledWith({
|
||||
organizationId: 200,
|
||||
userId: 1,
|
||||
teamId: 200,
|
||||
permission: "watchlist.read",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
entryId: "entry-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw UNAUTHORIZED when user lacks permission", async () => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
getWatchlistEntryDetailsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
id: "entry-123",
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to view blocklist entries",
|
||||
});
|
||||
|
||||
expect(mockWatchlistRepo.findEntryWithAuditAndReports).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should check permission with correct parameters", async () => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: [],
|
||||
});
|
||||
|
||||
await getWatchlistEntryDetailsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
id: "entry-123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
|
||||
userId: 1,
|
||||
teamId: 100,
|
||||
permission: "watchlist.read",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation", () => {
|
||||
beforeEach(() => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should throw NOT_FOUND when entry does not exist", async () => {
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: null,
|
||||
auditHistory: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
getWatchlistEntryDetailsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
id: "non-existent-id",
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Blocklist entry not found",
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw FORBIDDEN when entry belongs to different organization", async () => {
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: { ...mockEntry, organizationId: 999 },
|
||||
auditHistory: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
getWatchlistEntryDetailsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
id: "entry-123",
|
||||
},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
message: "You can only view blocklist entries from your organization",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("successful retrieval", () => {
|
||||
beforeEach(() => {
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should return entry and audit history when authorized", async () => {
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: mockAuditHistory,
|
||||
});
|
||||
mockUserRepo.findUsersByIds.mockResolvedValue(mockUsers);
|
||||
|
||||
const result = await getWatchlistEntryDetailsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
id: "entry-123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.entry).toEqual(mockEntry);
|
||||
expect(result.auditHistory).toHaveLength(2);
|
||||
expect(mockWatchlistRepo.findEntryWithAuditAndReports).toHaveBeenCalledWith("entry-123");
|
||||
});
|
||||
|
||||
it("should enrich audit history with user details", async () => {
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: mockAuditHistory,
|
||||
});
|
||||
mockUserRepo.findUsersByIds.mockResolvedValue(mockUsers);
|
||||
|
||||
const result = await getWatchlistEntryDetailsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
id: "entry-123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.auditHistory[0].changedByUser).toEqual(mockUsers[0]);
|
||||
expect(result.auditHistory[1].changedByUser).toEqual(mockUsers[1]);
|
||||
expect(mockUserRepo.findUsersByIds).toHaveBeenCalledWith([1, 2]);
|
||||
});
|
||||
|
||||
it("should handle audit history with duplicate user IDs", async () => {
|
||||
const auditWithDuplicates = [
|
||||
{ ...mockAuditHistory[0], changedByUserId: 1 },
|
||||
{ ...mockAuditHistory[1], changedByUserId: 1 },
|
||||
];
|
||||
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: auditWithDuplicates,
|
||||
});
|
||||
mockUserRepo.findUsersByIds.mockResolvedValue([mockUsers[0]]);
|
||||
|
||||
const result = await getWatchlistEntryDetailsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
id: "entry-123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockUserRepo.findUsersByIds).toHaveBeenCalledWith([1]);
|
||||
expect(result.auditHistory[0].changedByUser).toEqual(mockUsers[0]);
|
||||
expect(result.auditHistory[1].changedByUser).toEqual(mockUsers[0]);
|
||||
});
|
||||
|
||||
it("should handle audit history with null user IDs", async () => {
|
||||
const auditWithNulls = [
|
||||
{ ...mockAuditHistory[0], changedByUserId: null },
|
||||
{ ...mockAuditHistory[1], changedByUserId: 2 },
|
||||
];
|
||||
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: auditWithNulls,
|
||||
});
|
||||
mockUserRepo.findUsersByIds.mockResolvedValue([mockUsers[1]]);
|
||||
|
||||
const result = await getWatchlistEntryDetailsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
id: "entry-123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockUserRepo.findUsersByIds).toHaveBeenCalledWith([2]);
|
||||
expect(result.auditHistory[0].changedByUser).toBeUndefined();
|
||||
expect(result.auditHistory[1].changedByUser).toEqual(mockUsers[1]);
|
||||
});
|
||||
|
||||
it("should handle audit history with undefined user IDs", async () => {
|
||||
const auditWithUndefined = [
|
||||
{ ...mockAuditHistory[0], changedByUserId: undefined },
|
||||
{ ...mockAuditHistory[1], changedByUserId: 2 },
|
||||
];
|
||||
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: auditWithUndefined,
|
||||
});
|
||||
mockUserRepo.findUsersByIds.mockResolvedValue([mockUsers[1]]);
|
||||
|
||||
const result = await getWatchlistEntryDetailsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
id: "entry-123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockUserRepo.findUsersByIds).toHaveBeenCalledWith([2]);
|
||||
expect(result.auditHistory[0].changedByUser).toBeUndefined();
|
||||
expect(result.auditHistory[1].changedByUser).toEqual(mockUsers[1]);
|
||||
});
|
||||
|
||||
it("should not call findUsersByIds when audit history is empty", async () => {
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: [],
|
||||
});
|
||||
|
||||
const result = await getWatchlistEntryDetailsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
id: "entry-123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.auditHistory).toHaveLength(0);
|
||||
expect(mockUserRepo.findUsersByIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle when user is not found in user map", async () => {
|
||||
mockWatchlistRepo.findEntryWithAuditAndReports.mockResolvedValue({
|
||||
entry: mockEntry,
|
||||
auditHistory: [mockAuditHistory[0]],
|
||||
});
|
||||
mockUserRepo.findUsersByIds.mockResolvedValue([]);
|
||||
|
||||
const result = await getWatchlistEntryDetailsHandler({
|
||||
ctx: { user: mockUser },
|
||||
input: {
|
||||
id: "entry-123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.auditHistory[0].changedByUser).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+33
-56
@@ -1,8 +1,5 @@
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { getOrganizationWatchlistQueryService } from "@calcom/features/di/watchlist/containers/watchlist";
|
||||
import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
@@ -27,59 +24,39 @@ export const getWatchlistEntryDetailsHandler = async ({ ctx, input }: GetWatchli
|
||||
});
|
||||
}
|
||||
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
const hasPermission = await permissionCheckService.checkPermission({
|
||||
userId: user.id,
|
||||
teamId: organizationId,
|
||||
permission: "watchlist.read",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
const service = getOrganizationWatchlistQueryService();
|
||||
|
||||
try {
|
||||
return await service.getWatchlistEntryDetails({
|
||||
organizationId,
|
||||
userId: user.id,
|
||||
entryId: input.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WatchlistError) {
|
||||
switch (error.code) {
|
||||
case WatchlistErrorCode.NOT_FOUND:
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: error.message,
|
||||
});
|
||||
case WatchlistErrorCode.UNAUTHORIZED:
|
||||
case WatchlistErrorCode.PERMISSION_DENIED:
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: error.message,
|
||||
});
|
||||
default:
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to view blocklist entries",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to get blocklist entry details",
|
||||
});
|
||||
}
|
||||
|
||||
const watchlistRepo = new WatchlistRepository(prisma);
|
||||
const userRepo = new UserRepository(prisma);
|
||||
|
||||
const result = await watchlistRepo.findEntryWithAuditAndReports(input.id);
|
||||
|
||||
if (!result.entry) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Blocklist entry not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (result.entry.organizationId !== organizationId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You can only view blocklist entries from your organization",
|
||||
});
|
||||
}
|
||||
|
||||
const userIds = result.auditHistory
|
||||
.map((audit) => audit.changedByUserId)
|
||||
.filter((id): id is number => id !== null && id !== undefined);
|
||||
|
||||
const uniqueUserIds = Array.from(new Set(userIds));
|
||||
|
||||
const users = uniqueUserIds.length > 0 ? await userRepo.findUsersByIds(uniqueUserIds) : [];
|
||||
|
||||
const userMap = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
const auditHistoryWithUsers = result.auditHistory.map((audit) => ({
|
||||
...audit,
|
||||
changedByUser: audit.changedByUserId ? userMap.get(audit.changedByUserId) : undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
entry: result.entry,
|
||||
auditHistory: auditHistoryWithUsers,
|
||||
};
|
||||
};
|
||||
|
||||
export default getWatchlistEntryDetailsHandler;
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { getOrganizationWatchlistQueryService } from "@calcom/features/di/watchlist/containers/watchlist";
|
||||
import { WatchlistError, WatchlistErrorCode } from "@calcom/features/watchlist/lib/errors/WatchlistErrors";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
@@ -27,63 +24,37 @@ export const listWatchlistEntriesHandler = async ({ ctx, input }: ListWatchlistE
|
||||
});
|
||||
}
|
||||
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
const hasPermission = await permissionCheckService.checkPermission({
|
||||
userId: user.id,
|
||||
teamId: organizationId,
|
||||
permission: "watchlist.read",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
const service = getOrganizationWatchlistQueryService();
|
||||
|
||||
try {
|
||||
return await service.listWatchlistEntries({
|
||||
organizationId,
|
||||
userId: user.id,
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
searchTerm: input.searchTerm,
|
||||
filters: input.filters,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WatchlistError) {
|
||||
switch (error.code) {
|
||||
case WatchlistErrorCode.UNAUTHORIZED:
|
||||
case WatchlistErrorCode.PERMISSION_DENIED:
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: error.message,
|
||||
});
|
||||
default:
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to view blocklist entries",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to list blocklist entries",
|
||||
});
|
||||
}
|
||||
|
||||
const watchlistRepo = new WatchlistRepository(prisma);
|
||||
const userRepo = new UserRepository(prisma);
|
||||
|
||||
const result = await watchlistRepo.findAllEntries({
|
||||
organizationId,
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
searchTerm: input.searchTerm,
|
||||
filters: input.filters,
|
||||
});
|
||||
|
||||
const userIds = result.rows
|
||||
.map((entry) => entry.audits?.[0]?.changedByUserId)
|
||||
.filter((id): id is number => id !== null && id !== undefined);
|
||||
|
||||
const uniqueUserIds = Array.from(new Set(userIds));
|
||||
|
||||
const users = uniqueUserIds.length > 0 ? await userRepo.findUsersByIds(uniqueUserIds) : [];
|
||||
|
||||
const userMap = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
const rowsWithCreators = result.rows.map((entry) => {
|
||||
const audit = entry.audits?.[0];
|
||||
if (audit?.changedByUserId) {
|
||||
const changedByUser = userMap.get(audit.changedByUserId);
|
||||
return {
|
||||
...entry,
|
||||
audits: [
|
||||
{
|
||||
...audit,
|
||||
changedByUser,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
return {
|
||||
rows: rowsWithCreators,
|
||||
meta: result.meta,
|
||||
};
|
||||
};
|
||||
|
||||
export default listWatchlistEntriesHandler;
|
||||
|
||||
Reference in New Issue
Block a user