Skip to content

Commit 0c6a725

Browse files
committed
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
1 parent 600836c commit 0c6a725

File tree

5 files changed

+75
-74
lines changed

5 files changed

+75
-74
lines changed

apps/webapp/app/services/deleteOrganization.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { PrismaClient } from "@trigger.dev/database";
33
import { prisma } from "~/db.server";
44
import { featuresForRequest } from "~/features.server";
55
import { DeleteProjectService } from "./deleteProject.server";
6-
import { loopsClient } from "./loops.server";
6+
import { loopsClient } from "./loopsGlobal.server";
77
import { getCurrentPlan } from "./platform.v3.server";
88

99
export class DeleteOrganizationService {

apps/webapp/app/services/loops.server.ts

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
import { env } from "~/env.server";
2-
import { logger } from "./logger.server";
1+
import { logger as defaultLogger } from "./logger.server";
32

4-
class LoopsClient {
5-
constructor(private readonly apiKey: string) {}
3+
type Logger = Pick<typeof defaultLogger, "info" | "error">;
4+
5+
export class LoopsClient {
6+
#logger: Logger;
7+
8+
constructor(
9+
private readonly apiKey: string,
10+
logger: Logger = defaultLogger
11+
) {
12+
this.#logger = logger;
13+
}
614

715
async userCreated({
816
userId,
@@ -13,7 +21,7 @@ class LoopsClient {
1321
email: string;
1422
name: string | null;
1523
}) {
16-
logger.info(`Loops send "sign-up" event`, { userId, email, name });
24+
this.#logger.info(`Loops send "sign-up" event`, { userId, email, name });
1725
return this.#sendEvent({
1826
email,
1927
userId,
@@ -23,30 +31,41 @@ class LoopsClient {
2331
}
2432

2533
async deleteContact({ email }: { email: string }): Promise<boolean> {
26-
logger.info(`Loops deleting contact`, { email });
34+
this.#logger.info(`Loops deleting contact`, { email });
2735

2836
try {
29-
const response = await fetch(
30-
`https://app.loops.so/api/v1/contacts/${encodeURIComponent(email)}`,
31-
{
32-
method: "DELETE",
33-
headers: { Authorization: `Bearer ${this.apiKey}` },
34-
}
35-
);
37+
const response = await fetch("https://app.loops.so/api/v1/contacts/delete", {
38+
method: "POST",
39+
headers: {
40+
Authorization: `Bearer ${this.apiKey}`,
41+
"Content-Type": "application/json",
42+
},
43+
body: JSON.stringify({ email }),
44+
});
3645

3746
if (!response.ok) {
38-
// 404 is okay - contact already deleted
39-
if (response.status === 404) {
40-
logger.info(`Loops contact already deleted`, { email });
47+
this.#logger.error(`Loops deleteContact bad status`, { status: response.status, email });
48+
return false;
49+
}
50+
51+
const responseBody = (await response.json()) as { success: boolean; message?: string };
52+
53+
if (!responseBody.success) {
54+
// "Contact not found" means already deleted - treat as success
55+
if (responseBody.message === "Contact not found.") {
56+
this.#logger.info(`Loops contact already deleted`, { email });
4157
return true;
4258
}
43-
logger.error(`Loops deleteContact bad status`, { status: response.status, email });
59+
this.#logger.error(`Loops deleteContact failed response`, {
60+
message: responseBody.message,
61+
email,
62+
});
4463
return false;
4564
}
4665

4766
return true;
4867
} catch (error) {
49-
logger.error(`Loops deleteContact failed`, { error, email });
68+
this.#logger.error(`Loops deleteContact failed`, { error, email });
5069
return false;
5170
}
5271
}
@@ -80,7 +99,7 @@ class LoopsClient {
8099
const response = await fetch("https://app.loops.so/api/v1/events/send", options);
81100

82101
if (!response.ok) {
83-
logger.error(`Loops sendEvent ${eventName} bad status`, {
102+
this.#logger.error(`Loops sendEvent ${eventName} bad status`, {
84103
status: response.status,
85104
email,
86105
userId,
@@ -94,18 +113,16 @@ class LoopsClient {
94113
const responseBody = (await response.json()) as any;
95114

96115
if (!responseBody.success) {
97-
logger.error(`Loops sendEvent ${eventName} failed response`, {
116+
this.#logger.error(`Loops sendEvent ${eventName} failed response`, {
98117
message: responseBody.message,
99118
});
100119
return false;
101120
}
102121

103122
return true;
104123
} catch (error) {
105-
logger.error(`Loops sendEvent ${eventName} failed`, { error });
124+
this.#logger.error(`Loops sendEvent ${eventName} failed`, { error });
106125
return false;
107126
}
108127
}
109128
}
110-
111-
export const loopsClient = env.LOOPS_API_KEY ? new LoopsClient(env.LOOPS_API_KEY) : null;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { env } from "~/env.server";
2+
import { LoopsClient } from "./loops.server";
3+
4+
export const loopsClient = env.LOOPS_API_KEY ? new LoopsClient(env.LOOPS_API_KEY) : null;

apps/webapp/app/services/telemetry.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { Organization } from "~/models/organization.server";
55
import type { Project } from "~/models/project.server";
66
import type { User } from "~/models/user.server";
77
import { singleton } from "~/utils/singleton";
8-
import { loopsClient } from "./loops.server";
8+
import { loopsClient } from "./loopsGlobal.server";
99

1010
type Options = {
1111
postHogApiKey?: string;
Lines changed: 29 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,11 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { LoopsClient } from "../app/services/loops.server";
23

3-
// We need to test the LoopsClient class directly, so we'll create a test instance
4-
// rather than importing the singleton (which depends on env vars)
5-
6-
class LoopsClient {
7-
constructor(private readonly apiKey: string) {}
8-
9-
async deleteContact({ email }: { email: string }): Promise<boolean> {
10-
try {
11-
const response = await fetch(
12-
`https://app.loops.so/api/v1/contacts/${encodeURIComponent(email)}`,
13-
{
14-
method: "DELETE",
15-
headers: { Authorization: `Bearer ${this.apiKey}` },
16-
}
17-
);
18-
19-
if (!response.ok) {
20-
// 404 is okay - contact already deleted
21-
if (response.status === 404) {
22-
return true;
23-
}
24-
return false;
25-
}
26-
27-
return true;
28-
} catch (error) {
29-
return false;
30-
}
31-
}
32-
}
4+
// No-op logger for tests
5+
const noopLogger = {
6+
info: () => {},
7+
error: () => {},
8+
};
339

3410
describe("LoopsClient", () => {
3511
const originalFetch = global.fetch;
@@ -45,32 +21,38 @@ describe("LoopsClient", () => {
4521
});
4622

4723
describe("deleteContact", () => {
48-
it("should return true on successful deletion (200)", async () => {
24+
it("should return true on successful deletion", async () => {
4925
mockFetch.mockResolvedValueOnce({
5026
ok: true,
5127
status: 200,
28+
json: async () => ({ success: true, message: "Contact deleted." }),
5229
});
5330

54-
const client = new LoopsClient("test-api-key");
31+
const client = new LoopsClient("test-api-key", noopLogger);
5532
const result = await client.deleteContact({ email: "test@example.com" });
5633

5734
expect(result).toBe(true);
5835
expect(mockFetch).toHaveBeenCalledWith(
59-
"https://app.loops.so/api/v1/contacts/test%40example.com",
36+
"https://app.loops.so/api/v1/contacts/delete",
6037
{
61-
method: "DELETE",
62-
headers: { Authorization: "Bearer test-api-key" },
38+
method: "POST",
39+
headers: {
40+
Authorization: "Bearer test-api-key",
41+
"Content-Type": "application/json",
42+
},
43+
body: JSON.stringify({ email: "test@example.com" }),
6344
}
6445
);
6546
});
6647

67-
it("should return true when contact already deleted (404)", async () => {
48+
it("should return true when contact not found (already deleted)", async () => {
6849
mockFetch.mockResolvedValueOnce({
69-
ok: false,
70-
status: 404,
50+
ok: true,
51+
status: 200,
52+
json: async () => ({ success: false, message: "Contact not found." }),
7153
});
7254

73-
const client = new LoopsClient("test-api-key");
55+
const client = new LoopsClient("test-api-key", noopLogger);
7456
const result = await client.deleteContact({ email: "test@example.com" });
7557

7658
expect(result).toBe(true);
@@ -82,7 +64,7 @@ describe("LoopsClient", () => {
8264
status: 500,
8365
});
8466

85-
const client = new LoopsClient("test-api-key");
67+
const client = new LoopsClient("test-api-key", noopLogger);
8668
const result = await client.deleteContact({ email: "test@example.com" });
8769

8870
expect(result).toBe(false);
@@ -94,7 +76,7 @@ describe("LoopsClient", () => {
9476
status: 401,
9577
});
9678

97-
const client = new LoopsClient("test-api-key");
79+
const client = new LoopsClient("test-api-key", noopLogger);
9880
const result = await client.deleteContact({ email: "test@example.com" });
9981

10082
expect(result).toBe(false);
@@ -103,25 +85,23 @@ describe("LoopsClient", () => {
10385
it("should return false on network error", async () => {
10486
mockFetch.mockRejectedValueOnce(new Error("Network error"));
10587

106-
const client = new LoopsClient("test-api-key");
88+
const client = new LoopsClient("test-api-key", noopLogger);
10789
const result = await client.deleteContact({ email: "test@example.com" });
10890

10991
expect(result).toBe(false);
11092
});
11193

112-
it("should properly encode email addresses with special characters", async () => {
94+
it("should return false on other failure responses", async () => {
11395
mockFetch.mockResolvedValueOnce({
11496
ok: true,
11597
status: 200,
98+
json: async () => ({ success: false, message: "Some other error" }),
11699
});
117100

118-
const client = new LoopsClient("test-api-key");
119-
await client.deleteContact({ email: "test+alias@example.com" });
101+
const client = new LoopsClient("test-api-key", noopLogger);
102+
const result = await client.deleteContact({ email: "test@example.com" });
120103

121-
expect(mockFetch).toHaveBeenCalledWith(
122-
"https://app.loops.so/api/v1/contacts/test%2Balias%40example.com",
123-
expect.any(Object)
124-
);
104+
expect(result).toBe(false);
125105
});
126106
});
127107
});

0 commit comments

Comments
 (0)