Skip to content

Commit 600836c

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. - Idempotent: safe to call multiple times - Non-blocking: Loops failures do not block org deletion - Conditional: only runs when user has no remaining organizations
1 parent e536d35 commit 600836c

File tree

3 files changed

+179
-0
lines changed

3 files changed

+179
-0
lines changed

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +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";
67
import { getCurrentPlan } from "./platform.v3.server";
78

89
export class DeleteOrganizationService {
@@ -82,5 +83,27 @@ export class DeleteOrganizationService {
8283
deletedAt: new Date(),
8384
},
8485
});
86+
87+
// Unsubscribe user from Loops if this was their last organization
88+
const otherOrgs = await this.#prismaClient.organization.count({
89+
where: {
90+
members: { some: { userId } },
91+
deletedAt: null,
92+
id: { not: organization.id },
93+
},
94+
});
95+
96+
if (otherOrgs === 0) {
97+
// This was user's last org - delete from Loops
98+
const user = await this.#prismaClient.user.findUnique({
99+
where: { id: userId },
100+
select: { email: true },
101+
});
102+
103+
if (user) {
104+
// Fire and forget - don't block deletion on Loops API
105+
loopsClient?.deleteContact({ email: user.email });
106+
}
107+
}
85108
}
86109
}

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,35 @@ class LoopsClient {
2222
});
2323
}
2424

25+
async deleteContact({ email }: { email: string }): Promise<boolean> {
26+
logger.info(`Loops deleting contact`, { email });
27+
28+
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+
);
36+
37+
if (!response.ok) {
38+
// 404 is okay - contact already deleted
39+
if (response.status === 404) {
40+
logger.info(`Loops contact already deleted`, { email });
41+
return true;
42+
}
43+
logger.error(`Loops deleteContact bad status`, { status: response.status, email });
44+
return false;
45+
}
46+
47+
return true;
48+
} catch (error) {
49+
logger.error(`Loops deleteContact failed`, { error, email });
50+
return false;
51+
}
52+
}
53+
2554
async #sendEvent({
2655
email,
2756
userId,
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
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+
}
33+
34+
describe("LoopsClient", () => {
35+
const originalFetch = global.fetch;
36+
let mockFetch: ReturnType<typeof vi.fn>;
37+
38+
beforeEach(() => {
39+
mockFetch = vi.fn();
40+
global.fetch = mockFetch;
41+
});
42+
43+
afterEach(() => {
44+
global.fetch = originalFetch;
45+
});
46+
47+
describe("deleteContact", () => {
48+
it("should return true on successful deletion (200)", async () => {
49+
mockFetch.mockResolvedValueOnce({
50+
ok: true,
51+
status: 200,
52+
});
53+
54+
const client = new LoopsClient("test-api-key");
55+
const result = await client.deleteContact({ email: "test@example.com" });
56+
57+
expect(result).toBe(true);
58+
expect(mockFetch).toHaveBeenCalledWith(
59+
"https://app.loops.so/api/v1/contacts/test%40example.com",
60+
{
61+
method: "DELETE",
62+
headers: { Authorization: "Bearer test-api-key" },
63+
}
64+
);
65+
});
66+
67+
it("should return true when contact already deleted (404)", async () => {
68+
mockFetch.mockResolvedValueOnce({
69+
ok: false,
70+
status: 404,
71+
});
72+
73+
const client = new LoopsClient("test-api-key");
74+
const result = await client.deleteContact({ email: "test@example.com" });
75+
76+
expect(result).toBe(true);
77+
});
78+
79+
it("should return false on API error (500)", async () => {
80+
mockFetch.mockResolvedValueOnce({
81+
ok: false,
82+
status: 500,
83+
});
84+
85+
const client = new LoopsClient("test-api-key");
86+
const result = await client.deleteContact({ email: "test@example.com" });
87+
88+
expect(result).toBe(false);
89+
});
90+
91+
it("should return false on unauthorized (401)", async () => {
92+
mockFetch.mockResolvedValueOnce({
93+
ok: false,
94+
status: 401,
95+
});
96+
97+
const client = new LoopsClient("test-api-key");
98+
const result = await client.deleteContact({ email: "test@example.com" });
99+
100+
expect(result).toBe(false);
101+
});
102+
103+
it("should return false on network error", async () => {
104+
mockFetch.mockRejectedValueOnce(new Error("Network error"));
105+
106+
const client = new LoopsClient("test-api-key");
107+
const result = await client.deleteContact({ email: "test@example.com" });
108+
109+
expect(result).toBe(false);
110+
});
111+
112+
it("should properly encode email addresses with special characters", async () => {
113+
mockFetch.mockResolvedValueOnce({
114+
ok: true,
115+
status: 200,
116+
});
117+
118+
const client = new LoopsClient("test-api-key");
119+
await client.deleteContact({ email: "test+alias@example.com" });
120+
121+
expect(mockFetch).toHaveBeenCalledWith(
122+
"https://app.loops.so/api/v1/contacts/test%2Balias%40example.com",
123+
expect.any(Object)
124+
);
125+
});
126+
});
127+
});

0 commit comments

Comments
 (0)