diff --git a/apps/webapp/app/services/deleteOrganization.server.ts b/apps/webapp/app/services/deleteOrganization.server.ts index 2eef188d5f..ea6ce081d8 100644 --- a/apps/webapp/app/services/deleteOrganization.server.ts +++ b/apps/webapp/app/services/deleteOrganization.server.ts @@ -3,6 +3,7 @@ import { PrismaClient } from "@trigger.dev/database"; import { prisma } from "~/db.server"; import { featuresForRequest } from "~/features.server"; import { DeleteProjectService } from "./deleteProject.server"; +import { loopsClient } from "./loopsGlobal.server"; import { getCurrentPlan } from "./platform.v3.server"; export class DeleteOrganizationService { @@ -82,5 +83,27 @@ export class DeleteOrganizationService { deletedAt: new Date(), }, }); + + // Unsubscribe user from Loops if this was their last organization + const otherOrgs = await this.#prismaClient.organization.count({ + where: { + members: { some: { userId } }, + deletedAt: null, + id: { not: organization.id }, + }, + }); + + if (otherOrgs === 0) { + // This was user's last org - delete from Loops + const user = await this.#prismaClient.user.findUnique({ + where: { id: userId }, + select: { email: true }, + }); + + if (user) { + // Fire and forget - don't block deletion on Loops API + loopsClient?.deleteContact({ email: user.email }); + } + } } } diff --git a/apps/webapp/app/services/loops.server.ts b/apps/webapp/app/services/loops.server.ts index 6509d89470..43a3620f03 100644 --- a/apps/webapp/app/services/loops.server.ts +++ b/apps/webapp/app/services/loops.server.ts @@ -1,8 +1,16 @@ -import { env } from "~/env.server"; -import { logger } from "./logger.server"; +import { logger as defaultLogger } from "./logger.server"; -class LoopsClient { - constructor(private readonly apiKey: string) {} +type Logger = Pick; + +export class LoopsClient { + #logger: Logger; + + constructor( + private readonly apiKey: string, + logger: Logger = defaultLogger + ) { + this.#logger = logger; + } async userCreated({ userId, @@ -13,7 +21,7 @@ class LoopsClient { email: string; name: string | null; }) { - logger.info(`Loops send "sign-up" event`, { userId, email, name }); + this.#logger.info(`Loops send "sign-up" event`, { userId, email, name }); return this.#sendEvent({ email, userId, @@ -22,6 +30,51 @@ class LoopsClient { }); } + async deleteContact({ email }: { email: string }): Promise { + this.#logger.info(`Loops deleting contact`, { email }); + + try { + const response = await fetch("https://app.loops.so/api/v1/contacts/delete", { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ email }), + }); + + if (response.status === 404) { + this.#logger.info(`Loops contact already deleted`, { email }); + return true; + } + + if (!response.ok) { + this.#logger.error(`Loops deleteContact bad status`, { status: response.status, email }); + return false; + } + + const responseBody = (await response.json()) as { success: boolean; message?: string }; + + if (!responseBody.success) { + // "Contact not found" means already deleted - treat as success + if (responseBody.message === "Contact not found.") { + this.#logger.info(`Loops contact already deleted`, { email }); + return true; + } + this.#logger.error(`Loops deleteContact failed response`, { + message: responseBody.message, + email, + }); + return false; + } + + return true; + } catch (error) { + this.#logger.error(`Loops deleteContact failed`, { error, email }); + return false; + } + } + async #sendEvent({ email, userId, @@ -51,7 +104,7 @@ class LoopsClient { const response = await fetch("https://app.loops.so/api/v1/events/send", options); if (!response.ok) { - logger.error(`Loops sendEvent ${eventName} bad status`, { + this.#logger.error(`Loops sendEvent ${eventName} bad status`, { status: response.status, email, userId, @@ -65,7 +118,7 @@ class LoopsClient { const responseBody = (await response.json()) as any; if (!responseBody.success) { - logger.error(`Loops sendEvent ${eventName} failed response`, { + this.#logger.error(`Loops sendEvent ${eventName} failed response`, { message: responseBody.message, }); return false; @@ -73,10 +126,8 @@ class LoopsClient { return true; } catch (error) { - logger.error(`Loops sendEvent ${eventName} failed`, { error }); + this.#logger.error(`Loops sendEvent ${eventName} failed`, { error }); return false; } } } - -export const loopsClient = env.LOOPS_API_KEY ? new LoopsClient(env.LOOPS_API_KEY) : null; diff --git a/apps/webapp/app/services/loopsGlobal.server.ts b/apps/webapp/app/services/loopsGlobal.server.ts new file mode 100644 index 0000000000..006f94cda3 --- /dev/null +++ b/apps/webapp/app/services/loopsGlobal.server.ts @@ -0,0 +1,4 @@ +import { env } from "~/env.server"; +import { LoopsClient } from "./loops.server"; + +export const loopsClient = env.LOOPS_API_KEY ? new LoopsClient(env.LOOPS_API_KEY) : null; diff --git a/apps/webapp/app/services/telemetry.server.ts b/apps/webapp/app/services/telemetry.server.ts index 98ca11ed90..33e57d67b9 100644 --- a/apps/webapp/app/services/telemetry.server.ts +++ b/apps/webapp/app/services/telemetry.server.ts @@ -5,7 +5,7 @@ import type { Organization } from "~/models/organization.server"; import type { Project } from "~/models/project.server"; import type { User } from "~/models/user.server"; import { singleton } from "~/utils/singleton"; -import { loopsClient } from "./loops.server"; +import { loopsClient } from "./loopsGlobal.server"; type Options = { postHogApiKey?: string; diff --git a/apps/webapp/test/loopsClient.test.ts b/apps/webapp/test/loopsClient.test.ts new file mode 100644 index 0000000000..5052fd78e3 --- /dev/null +++ b/apps/webapp/test/loopsClient.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { LoopsClient } from "../app/services/loops.server"; + +// No-op logger for tests +const noopLogger = { + info: () => {}, + error: () => {}, +}; + +describe("LoopsClient", () => { + const originalFetch = global.fetch; + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + describe("deleteContact", () => { + it("should return true on successful deletion", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ success: true, message: "Contact deleted." }), + }); + + const client = new LoopsClient("test-api-key", noopLogger); + const result = await client.deleteContact({ email: "test@example.com" }); + + expect(result).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + "https://app.loops.so/api/v1/contacts/delete", + { + method: "POST", + headers: { + Authorization: "Bearer test-api-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@example.com" }), + } + ); + }); + + it("should return true when contact not found (already deleted)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ success: false, message: "Contact not found." }), + }); + + const client = new LoopsClient("test-api-key", noopLogger); + const result = await client.deleteContact({ email: "test@example.com" }); + + expect(result).toBe(true); + }); + + it("should return true when API returns 404 (contact already deleted)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const client = new LoopsClient("test-api-key", noopLogger); + const result = await client.deleteContact({ email: "test@example.com" }); + + expect(result).toBe(true); + }); + + it("should return false on API error (500)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + const client = new LoopsClient("test-api-key", noopLogger); + const result = await client.deleteContact({ email: "test@example.com" }); + + expect(result).toBe(false); + }); + + it("should return false on unauthorized (401)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + }); + + const client = new LoopsClient("test-api-key", noopLogger); + const result = await client.deleteContact({ email: "test@example.com" }); + + expect(result).toBe(false); + }); + + it("should return false on network error", async () => { + mockFetch.mockRejectedValueOnce(new Error("Network error")); + + const client = new LoopsClient("test-api-key", noopLogger); + const result = await client.deleteContact({ email: "test@example.com" }); + + expect(result).toBe(false); + }); + + it("should return false on other failure responses", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ success: false, message: "Some other error" }), + }); + + const client = new LoopsClient("test-api-key", noopLogger); + const result = await client.deleteContact({ email: "test@example.com" }); + + expect(result).toBe(false); + }); + }); +});