diff --git a/apps/api/src/google/google-connection.service.ts b/apps/api/src/google/google-connection.service.ts index 839bb938..3d02f740 100644 --- a/apps/api/src/google/google-connection.service.ts +++ b/apps/api/src/google/google-connection.service.ts @@ -1,6 +1,18 @@ -import { isGoogleConfigured, signsInWithGoogle } from "@crm/auth"; +import { + isGoogleConfigured, + isWorkspaceAdmin, + isWorkspaceRole, + signsInWithGoogle, + WORKSPACE_ID, + type WorkspaceRole, +} from "@crm/auth"; import { type Db, GoogleSyncStatus } from "@crm/db"; -import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; import { normalizeDomain } from "../companies/domain"; import { ActivityStampService } from "../crm/activity-stamp.service"; import { InjectDatabase } from "../database/database.constants"; @@ -159,9 +171,18 @@ export class GoogleConnectionService { } async suppressDomain( + userId: string, domain: string, options: { reason?: string; purge: boolean }, ): Promise<{ domain: string; purged: number }> { + const role = await this.roleOf(userId); + + if (!isWorkspaceAdmin(role)) { + throw new ForbiddenException( + "Only an owner or an admin can suppress a domain.", + ); + } + const normalised = normalizeDomain(domain); if (!normalised) { throw new NotFoundException(`"${domain}" is not a domain.`); @@ -204,4 +225,19 @@ export class GoogleConnectionService { return { domain: normalised, purged: threads.count + events.count }; } + + private async roleOf(userId: string): Promise { + const member = await this.db.member.findUnique({ + where: { + organizationId_userId: { organizationId: WORKSPACE_ID, userId }, + }, + select: { role: true }, + }); + + return member ? toRole(member.role) : null; + } +} + +function toRole(value: string): WorkspaceRole { + return isWorkspaceRole(value) ? value : "member"; } diff --git a/apps/api/src/google/google.router.ts b/apps/api/src/google/google.router.ts index b16cf15d..910494d0 100644 --- a/apps/api/src/google/google.router.ts +++ b/apps/api/src/google/google.router.ts @@ -66,8 +66,11 @@ export class GoogleRouter { } @Mutation({ input: suppressDomainInput }) - async suppressDomain(@Input() input: z.infer) { - return this.connection.suppressDomain(input.domain, { + async suppressDomain( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + return this.connection.suppressDomain(ctx.user.id, input.domain, { reason: input.reason, purge: input.purge, }); diff --git a/apps/api/test/google-connection.spec.ts b/apps/api/test/google-connection.spec.ts new file mode 100644 index 00000000..f8baf4e4 --- /dev/null +++ b/apps/api/test/google-connection.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "bun:test"; +import type { Db } from "@crm/db"; +import { ForbiddenException } from "@nestjs/common"; +import type { ActivityStampService } from "../src/crm/activity-stamp.service"; +import { GoogleConnectionService } from "../src/google/google-connection.service"; +import type { GoogleMatchService } from "../src/google/google-match.service"; +import type { GoogleTokenService } from "../src/google/google-token.service"; +import type { SyncStateService } from "../src/google/sync-state.service"; + +function connection(role: string | null): { + service: GoogleConnectionService; + deleted: { threads: number; events: number }; +} { + const deleted = { threads: 0, events: 0 }; + const db = { + member: { + findUnique: async () => (role === null ? null : { role }), + }, + suppressedDomain: { + upsert: async () => ({ domain: "globex.com" }), + }, + company: { + findUnique: async () => ({ id: "company-1" }), + }, + $transaction: async (ops: unknown) => { + const list = (await Promise.all( + ops as Array>, + )) as Array<{ count: number }>; + const threads = list[0]?.count ?? 0; + const events = list[1]?.count ?? 0; + deleted.threads = threads; + deleted.events = events; + return [{ count: threads }, { count: events }]; + }, + emailThread: { + deleteMany: async () => ({ count: 2 }), + }, + calendarEvent: { + deleteMany: async () => ({ count: 1 }), + }, + } as unknown as Db; + + const service = new GoogleConnectionService( + db, + {} as unknown as GoogleTokenService, + {} as unknown as SyncStateService, + { + internalIdentity: async () => ({ + domains: new Set(["acme.com"]), + addresses: new Set(), + }), + } as unknown as GoogleMatchService, + { + recomputeAll: async () => undefined, + } as unknown as ActivityStampService, + ); + + return { service, deleted }; +} + +describe("suppressDomain", () => { + it("refuses a member", async () => { + const { service } = connection("member"); + await expect( + service.suppressDomain("u1", "globex.com", { purge: true }), + ).rejects.toThrow(ForbiddenException); + }); + + it("refuses someone with no workspace membership", async () => { + const { service } = connection(null); + await expect( + service.suppressDomain("u1", "globex.com", { purge: true }), + ).rejects.toThrow(ForbiddenException); + }); + + it("lets an admin suppress without purging", async () => { + const { service } = connection("admin"); + const result = await service.suppressDomain("u1", "globex.com", { + purge: false, + }); + expect(result).toEqual({ domain: "globex.com", purged: 0 }); + }); + + it("lets an owner suppress and purge", async () => { + const { service, deleted } = connection("owner"); + const result = await service.suppressDomain("u1", "globex.com", { + purge: true, + }); + expect(result).toEqual({ domain: "globex.com", purged: 3 }); + expect(deleted.threads).toBe(2); + expect(deleted.events).toBe(1); + }); +});