diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 3a76843e70..211c236167 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3124,7 +3124,10 @@ "resend_in_seconds": "Resend in {{seconds}} seconds", "resend": "Resend", "verification_email_sent": "Verification email sent", - "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑", + "remove_whitelist_status": "Remove whitelist status", + "whitelist_user_workflows": "Whitelist user workflows", + "user_workflows_whitelisted": "User workflows whitelisted", + "user_workflows_unwhitelisted": "User workflows unwhitelisted", "date_and_time": "Date & Time", "email_reminder_upcoming_event_notice": "This is a reminder about your upcoming event.", "email_reminder_triggered_by_workflow": "This reminder was triggered by a Workflow in Cal.", @@ -3136,5 +3139,6 @@ "meeting_not_joined_question": "didn't join the meeting?", "reschedule_cta_short": "Reschedule here.", "you_and_conjunction": "You &", - "email_survey_triggered_by_workflow": "This survey was triggered by a Workflow in Cal." + "email_survey_triggered_by_workflow": "This survey was triggered by a Workflow in Cal.", + "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/ee/users/components/UsersTable.tsx b/packages/features/ee/users/components/UsersTable.tsx index 26fd9920de..da2dc2917b 100644 --- a/packages/features/ee/users/components/UsersTable.tsx +++ b/packages/features/ee/users/components/UsersTable.tsx @@ -122,6 +122,16 @@ function UsersTableBare() { const verifyWorkflows = trpc.viewer.admin.verifyWorkflows.useMutation({ onSuccess: () => { showToast("Workflows verified", "success"); + utils.viewer.admin.listPaginated.invalidate(); + }, + }); + const whitelistUserWorkflows = trpc.viewer.admin.whitelistUserWorkflows.useMutation({ + onSuccess: (data) => { + showToast( + data.whitelistWorkflows ? t("user_workflows_whitelisted") : t("user_workflows_unwhitelisted"), + "success" + ); + utils.viewer.admin.listPaginated.invalidate(); }, }); @@ -248,6 +258,19 @@ function UsersTableBare() { onClick: () => verifyWorkflows.mutate({ userId: user.id }), icon: "check", }, + { + id: "whitelist-user-workflows", + label: user.whitelistWorkflows + ? t("remove_whitelist_status") + : t("whitelist_user_workflows"), + onClick: () => { + whitelistUserWorkflows.mutate({ + userId: user.id, + whitelistWorkflows: !user.whitelistWorkflows, + }); + }, + icon: "check", + }, { id: "impersonation", label: "Impersonate", diff --git a/packages/features/tasker/tasks/scanWorkflowBody.test.ts b/packages/features/tasker/tasks/scanWorkflowBody.test.ts index 31642c3276..dcf36920f4 100644 --- a/packages/features/tasker/tasks/scanWorkflowBody.test.ts +++ b/packages/features/tasker/tasks/scanWorkflowBody.test.ts @@ -172,4 +172,23 @@ describe("scanWorkflowBody", () => { expect(scheduleWorkflowNotifications).not.toHaveBeenCalled(); }); + + it("should handle whitelisted user being flagged as spam", async () => { + const payload = JSON.stringify({ + userId: 1, + workflowStepIds: [1], + }); + + prismaMock.workflowStep.findMany.mockResolvedValue([ + { ...mockWorkflowStep, workflow: { user: { whitelistWorkflows: true } } }, + ]); + prismaMock.workflow.findFirst.mockResolvedValue(mockWorkflow); + mockAkismetCheckSpam.mockResolvedValue(true); + + await scanWorkflowBody(payload); + + expect(mockAkismetCheckSpam).toHaveBeenCalled(); + expect(prismaMock.workflowStep.update).not.toHaveBeenCalled(); + expect(lockUser).not.toHaveBeenCalled(); + }); }); diff --git a/packages/features/tasker/tasks/scanWorkflowBody.ts b/packages/features/tasker/tasks/scanWorkflowBody.ts index 75bc0fc5dc..49ab241d52 100644 --- a/packages/features/tasker/tasks/scanWorkflowBody.ts +++ b/packages/features/tasker/tasks/scanWorkflowBody.ts @@ -40,6 +40,7 @@ export async function scanWorkflowBody(payload: string) { select: { locale: true, timeFormat: true, + whitelistWorkflows: true, }, }, }, @@ -100,6 +101,13 @@ export async function scanWorkflowBody(payload: string) { const isSpam = await client.checkSpam(comment); if (isSpam) { + if (workflowStep.workflow.user?.whitelistWorkflows) { + log.warn( + `For whitelisted user, workflow step ${workflowStep.id} is spam with body ${workflowStep.reminderBody}` + ); + return; + } + // We won't delete the workflow step incase it is flagged as a false positive log.warn(`Workflow step ${workflowStep.id} is spam with body ${workflowStep.reminderBody}`); await lockUser("userId", userId.toString(), LockReason.SPAM_WORKFLOW_BODY); diff --git a/packages/lib/server/repository/user.ts b/packages/lib/server/repository/user.ts index 92f149ebef..194e9c9331 100644 --- a/packages/lib/server/repository/user.ts +++ b/packages/lib/server/repository/user.ts @@ -927,4 +927,17 @@ export class UserRepository { data: { metadata: { ...existingMetadata, stripeCustomerId } }, }); } + + static async updateWhitelistWorkflows({ + id, + whitelistWorkflows, + }: { + id: number; + whitelistWorkflows: boolean; + }) { + return prisma.user.update({ + where: { id }, + data: { whitelistWorkflows }, + }); + } } diff --git a/packages/prisma/migrations/20250415173346_add_whitelist_workflows_to_user/migration.sql b/packages/prisma/migrations/20250415173346_add_whitelist_workflows_to_user/migration.sql new file mode 100644 index 0000000000..ae605be9d9 --- /dev/null +++ b/packages/prisma/migrations/20250415173346_add_whitelist_workflows_to_user/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "whitelistWorkflows" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index cb4dffa22c..82b26f6991 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -384,6 +384,7 @@ model User { creationSource CreationSource? createdOrganizationOnboardings OrganizationOnboarding[] @relation("CreatedOrganizationOnboardings") filterSegments FilterSegment[] + whitelistWorkflows Boolean @default(false) @@unique([email]) @@unique([email, username]) diff --git a/packages/trpc/server/routers/viewer/admin/_router.ts b/packages/trpc/server/routers/viewer/admin/_router.ts index c0582bf101..49580eb947 100644 --- a/packages/trpc/server/routers/viewer/admin/_router.ts +++ b/packages/trpc/server/routers/viewer/admin/_router.ts @@ -8,6 +8,7 @@ import { ZAdminPasswordResetSchema } from "./sendPasswordReset.schema"; import { ZSetSMSLockState } from "./setSMSLockState.schema"; import { toggleFeatureFlag } from "./toggleFeatureFlag.procedure"; import { ZAdminVerifyWorkflowsSchema } from "./verifyWorkflows.schema"; +import { ZWhitelistUserWorkflows } from "./whitelistUserWorkflows.schema"; import { workspacePlatformCreateSchema, workspacePlatformUpdateSchema, @@ -55,6 +56,10 @@ export const adminRouter = router({ const { default: handler } = await import("./verifyWorkflows.handler"); return handler(opts); }), + whitelistUserWorkflows: authedAdminProcedure.input(ZWhitelistUserWorkflows).mutation(async (opts) => { + const { default: handler } = await import("./whitelistUserWorkflows.handler"); + return handler(opts); + }), workspacePlatform: router({ list: authedAdminProcedure.query(async () => { const { default: handler } = await import("./workspacePlatform/list.handler"); diff --git a/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts b/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts index 2529b942cd..c8e604f575 100644 --- a/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts +++ b/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts @@ -72,6 +72,7 @@ const listPaginatedHandler = async ({ input }: GetOptions) => { username: true, }, }, + whitelistWorkflows: true, }, }); diff --git a/packages/trpc/server/routers/viewer/admin/whitelistUserWorkflows.handler.ts b/packages/trpc/server/routers/viewer/admin/whitelistUserWorkflows.handler.ts new file mode 100644 index 0000000000..10e06e4ea0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/whitelistUserWorkflows.handler.ts @@ -0,0 +1,24 @@ +import { UserRepository } from "@calcom/lib/server/repository/user"; + +import type { TrpcSessionUser } from "../../../types"; +import type { TWhitelistUserWorkflows } from "./whitelistUserWorkflows.schema"; + +type GetOptions = { + ctx: { + user: NonNullable; + }; + input: TWhitelistUserWorkflows; +}; + +export const whitelistUserWorkflows = async ({ input }: GetOptions) => { + const { userId, whitelistWorkflows } = input; + + const user = await UserRepository.updateWhitelistWorkflows({ + id: userId, + whitelistWorkflows, + }); + + return { whitelistWorkflows: user.whitelistWorkflows }; +}; + +export default whitelistUserWorkflows; diff --git a/packages/trpc/server/routers/viewer/admin/whitelistUserWorkflows.schema.ts b/packages/trpc/server/routers/viewer/admin/whitelistUserWorkflows.schema.ts new file mode 100644 index 0000000000..0ea83b8e9e --- /dev/null +++ b/packages/trpc/server/routers/viewer/admin/whitelistUserWorkflows.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZWhitelistUserWorkflows = z.object({ + userId: z.number(), + whitelistWorkflows: z.boolean(), +}); + +export type TWhitelistUserWorkflows = z.infer;