From 600836c86744bd7d3193966aa6ebe9ae8e63991e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusufhan=20Sa=C3=A7ak?= Date: Sat, 7 Feb 2026 13:07:47 +0300 Subject: [PATCH 1/3] fix(webapp): remove deleted accounts from Loops marketing list (#3010) When a user deletes their last organization, remove their contact from Loops to prevent future marketing emails. - Idempotent: safe to call multiple times - Non-blocking: Loops failures do not block org deletion - Conditional: only runs when user has no remaining organizations --- .../app/services/deleteOrganization.server.ts | 23 ++++ apps/webapp/app/services/loops.server.ts | 29 ++++ apps/webapp/test/loopsClient.test.ts | 127 ++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 apps/webapp/test/loopsClient.test.ts diff --git a/apps/webapp/app/services/deleteOrganization.server.ts b/apps/webapp/app/services/deleteOrganization.server.ts index 2eef188d5f..4b6b32a5ff 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 "./loops.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..f1c45a51fe 100644 --- a/apps/webapp/app/services/loops.server.ts +++ b/apps/webapp/app/services/loops.server.ts @@ -22,6 +22,35 @@ class LoopsClient { }); } + async deleteContact({ email }: { email: string }): Promise { + logger.info(`Loops deleting contact`, { email }); + + try { + const response = await fetch( + `https://app.loops.so/api/v1/contacts/${encodeURIComponent(email)}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${this.apiKey}` }, + } + ); + + if (!response.ok) { + // 404 is okay - contact already deleted + if (response.status === 404) { + logger.info(`Loops contact already deleted`, { email }); + return true; + } + logger.error(`Loops deleteContact bad status`, { status: response.status, email }); + return false; + } + + return true; + } catch (error) { + logger.error(`Loops deleteContact failed`, { error, email }); + return false; + } + } + async #sendEvent({ email, userId, diff --git a/apps/webapp/test/loopsClient.test.ts b/apps/webapp/test/loopsClient.test.ts new file mode 100644 index 0000000000..add6429163 --- /dev/null +++ b/apps/webapp/test/loopsClient.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// We need to test the LoopsClient class directly, so we'll create a test instance +// rather than importing the singleton (which depends on env vars) + +class LoopsClient { + constructor(private readonly apiKey: string) {} + + async deleteContact({ email }: { email: string }): Promise { + try { + const response = await fetch( + `https://app.loops.so/api/v1/contacts/${encodeURIComponent(email)}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${this.apiKey}` }, + } + ); + + if (!response.ok) { + // 404 is okay - contact already deleted + if (response.status === 404) { + return true; + } + return false; + } + + return true; + } catch (error) { + return false; + } + } +} + +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 (200)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const client = new LoopsClient("test-api-key"); + const result = await client.deleteContact({ email: "test@example.com" }); + + expect(result).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + "https://app.loops.so/api/v1/contacts/test%40example.com", + { + method: "DELETE", + headers: { Authorization: "Bearer test-api-key" }, + } + ); + }); + + it("should return true when contact already deleted (404)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const client = new LoopsClient("test-api-key"); + 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"); + 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"); + 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"); + const result = await client.deleteContact({ email: "test@example.com" }); + + expect(result).toBe(false); + }); + + it("should properly encode email addresses with special characters", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const client = new LoopsClient("test-api-key"); + await client.deleteContact({ email: "test+alias@example.com" }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://app.loops.so/api/v1/contacts/test%2Balias%40example.com", + expect.any(Object) + ); + }); + }); +}); From 0c6a725aebe662316c3f5fcf6dba55d3a10ddf62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusufhan=20Sa=C3=A7ak?= Date: Sat, 7 Feb 2026 13:23:08 +0300 Subject: [PATCH 2/3] fix(webapp): remove deleted accounts from Loops marketing list (#3010) When a user deletes their last organization, remove their contact from Loops to prevent future marketing emails. - Add deleteContact() using the correct Loops v1 endpoint (POST /api/v1/contacts/delete) - Extract LoopsClient into a testable class (separate env singleton) - Wire Loops deletion into the organization deletion flow - Only trigger Loops removal when the user has no remaining organizations --- .../app/services/deleteOrganization.server.ts | 2 +- apps/webapp/app/services/loops.server.ts | 63 +++++++++------ .../webapp/app/services/loopsGlobal.server.ts | 4 + apps/webapp/app/services/telemetry.server.ts | 2 +- apps/webapp/test/loopsClient.test.ts | 78 +++++++------------ 5 files changed, 75 insertions(+), 74 deletions(-) create mode 100644 apps/webapp/app/services/loopsGlobal.server.ts diff --git a/apps/webapp/app/services/deleteOrganization.server.ts b/apps/webapp/app/services/deleteOrganization.server.ts index 4b6b32a5ff..ea6ce081d8 100644 --- a/apps/webapp/app/services/deleteOrganization.server.ts +++ b/apps/webapp/app/services/deleteOrganization.server.ts @@ -3,7 +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 "./loops.server"; +import { loopsClient } from "./loopsGlobal.server"; import { getCurrentPlan } from "./platform.v3.server"; export class DeleteOrganizationService { diff --git a/apps/webapp/app/services/loops.server.ts b/apps/webapp/app/services/loops.server.ts index f1c45a51fe..21a03c5acc 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, @@ -23,30 +31,41 @@ class LoopsClient { } async deleteContact({ email }: { email: string }): Promise { - logger.info(`Loops deleting contact`, { email }); + this.#logger.info(`Loops deleting contact`, { email }); try { - const response = await fetch( - `https://app.loops.so/api/v1/contacts/${encodeURIComponent(email)}`, - { - method: "DELETE", - headers: { Authorization: `Bearer ${this.apiKey}` }, - } - ); + 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.ok) { - // 404 is okay - contact already deleted - if (response.status === 404) { - logger.info(`Loops contact already deleted`, { email }); + 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; } - logger.error(`Loops deleteContact bad status`, { status: response.status, email }); + this.#logger.error(`Loops deleteContact failed response`, { + message: responseBody.message, + email, + }); return false; } return true; } catch (error) { - logger.error(`Loops deleteContact failed`, { error, email }); + this.#logger.error(`Loops deleteContact failed`, { error, email }); return false; } } @@ -80,7 +99,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, @@ -94,7 +113,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; @@ -102,10 +121,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 index add6429163..b25226c3a4 100644 --- a/apps/webapp/test/loopsClient.test.ts +++ b/apps/webapp/test/loopsClient.test.ts @@ -1,35 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { LoopsClient } from "../app/services/loops.server"; -// We need to test the LoopsClient class directly, so we'll create a test instance -// rather than importing the singleton (which depends on env vars) - -class LoopsClient { - constructor(private readonly apiKey: string) {} - - async deleteContact({ email }: { email: string }): Promise { - try { - const response = await fetch( - `https://app.loops.so/api/v1/contacts/${encodeURIComponent(email)}`, - { - method: "DELETE", - headers: { Authorization: `Bearer ${this.apiKey}` }, - } - ); - - if (!response.ok) { - // 404 is okay - contact already deleted - if (response.status === 404) { - return true; - } - return false; - } - - return true; - } catch (error) { - return false; - } - } -} +// No-op logger for tests +const noopLogger = { + info: () => {}, + error: () => {}, +}; describe("LoopsClient", () => { const originalFetch = global.fetch; @@ -45,32 +21,38 @@ describe("LoopsClient", () => { }); describe("deleteContact", () => { - it("should return true on successful deletion (200)", async () => { + 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"); + 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/test%40example.com", + "https://app.loops.so/api/v1/contacts/delete", { - method: "DELETE", - headers: { Authorization: "Bearer test-api-key" }, + 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 already deleted (404)", async () => { + it("should return true when contact not found (already deleted)", async () => { mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, + ok: true, + status: 200, + json: async () => ({ success: false, message: "Contact not found." }), }); - const client = new LoopsClient("test-api-key"); + const client = new LoopsClient("test-api-key", noopLogger); const result = await client.deleteContact({ email: "test@example.com" }); expect(result).toBe(true); @@ -82,7 +64,7 @@ describe("LoopsClient", () => { status: 500, }); - const client = new LoopsClient("test-api-key"); + const client = new LoopsClient("test-api-key", noopLogger); const result = await client.deleteContact({ email: "test@example.com" }); expect(result).toBe(false); @@ -94,7 +76,7 @@ describe("LoopsClient", () => { status: 401, }); - const client = new LoopsClient("test-api-key"); + const client = new LoopsClient("test-api-key", noopLogger); const result = await client.deleteContact({ email: "test@example.com" }); expect(result).toBe(false); @@ -103,25 +85,23 @@ describe("LoopsClient", () => { it("should return false on network error", async () => { mockFetch.mockRejectedValueOnce(new Error("Network error")); - const client = new LoopsClient("test-api-key"); + const client = new LoopsClient("test-api-key", noopLogger); const result = await client.deleteContact({ email: "test@example.com" }); expect(result).toBe(false); }); - it("should properly encode email addresses with special characters", async () => { + 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"); - await client.deleteContact({ email: "test+alias@example.com" }); + const client = new LoopsClient("test-api-key", noopLogger); + const result = await client.deleteContact({ email: "test@example.com" }); - expect(mockFetch).toHaveBeenCalledWith( - "https://app.loops.so/api/v1/contacts/test%2Balias%40example.com", - expect.any(Object) - ); + expect(result).toBe(false); }); }); }); From 21f85f2d06bc98e09f5725de76d9d2555c58c181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusufhan=20Sa=C3=A7ak?= Date: Sat, 7 Feb 2026 13:35:59 +0300 Subject: [PATCH 3/3] fix(webapp): treat Loops 404 as successful contact deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When deleting a Loops contact, a 404 response indicates the contact doesn’t exist, which is already the desired end state. Handle this explicitly by returning true instead of falling through to the generic error path. - Treat HTTP 404 from Loops delete endpoint as success - Add test coverage for the 404 case --- apps/webapp/app/services/loops.server.ts | 5 +++++ apps/webapp/test/loopsClient.test.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/apps/webapp/app/services/loops.server.ts b/apps/webapp/app/services/loops.server.ts index 21a03c5acc..43a3620f03 100644 --- a/apps/webapp/app/services/loops.server.ts +++ b/apps/webapp/app/services/loops.server.ts @@ -43,6 +43,11 @@ export class LoopsClient { 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; diff --git a/apps/webapp/test/loopsClient.test.ts b/apps/webapp/test/loopsClient.test.ts index b25226c3a4..5052fd78e3 100644 --- a/apps/webapp/test/loopsClient.test.ts +++ b/apps/webapp/test/loopsClient.test.ts @@ -58,6 +58,18 @@ describe("LoopsClient", () => { 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,