Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/webapp/app/services/deleteOrganization.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 });
}
}
}
}
71 changes: 61 additions & 10 deletions apps/webapp/app/services/loops.server.ts
Original file line number Diff line number Diff line change
@@ -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<typeof defaultLogger, "info" | "error">;

export class LoopsClient {
#logger: Logger;

constructor(
private readonly apiKey: string,
logger: Logger = defaultLogger
) {
this.#logger = logger;
}

async userCreated({
userId,
Expand All @@ -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,
Expand All @@ -22,6 +30,51 @@ class LoopsClient {
});
}

async deleteContact({ email }: { email: string }): Promise<boolean> {
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,
Expand Down Expand Up @@ -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,
Expand All @@ -65,18 +118,16 @@ 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;
}

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;
4 changes: 4 additions & 0 deletions apps/webapp/app/services/loopsGlobal.server.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion apps/webapp/app/services/telemetry.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
119 changes: 119 additions & 0 deletions apps/webapp/test/loopsClient.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;

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);
});
});
});