From 740c899764fbb6797e19dd1ad00cbb25e63c7e50 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Mon, 11 May 2026 21:24:23 -0400 Subject: [PATCH] Create Loops profiles on user signup (#180) --- src/env.d.ts | 3 ++ src/lib/auth.ts | 20 +++++++++++ src/server/email/loops.ts | 72 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) diff --git a/src/env.d.ts b/src/env.d.ts index 73c778b..83b78b8 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -13,6 +13,9 @@ declare namespace Cloudflare { POSTHOG_HOST?: string; BETTER_AUTH_SECRET?: string; BETTER_AUTH_URL?: string; + LOOPS_API_KEY?: string; + LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID?: string; + LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID?: string; // DataForSEO API Basic auth value (base64 of login:password) DATAFORSEO_API_KEY: string; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 565cb37..eb0ba2b 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -9,6 +9,7 @@ import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-host import { sendHostedPasswordResetEmail, sendHostedVerificationEmail, + upsertHostedSignupContact, } from "@/server/email/loops"; const hostedBaseUrlSchema = z @@ -61,6 +62,25 @@ function createAuth() { }), plugins: [...baseAuthConfig.plugins, tanstackStartCookies()], databaseHooks: { + user: { + create: { + after: async (user) => { + try { + await upsertHostedSignupContact({ + userId: user.id, + email: user.email, + name: user.name, + }); + } catch (error) { + console.error("Failed to create Loops profile for signup:", { + userId: user.id, + email: user.email, + error, + }); + } + }, + }, + }, session: { create: { before: async (session) => { diff --git a/src/server/email/loops.ts b/src/server/email/loops.ts index 37e42ed..86a3c40 100644 --- a/src/server/email/loops.ts +++ b/src/server/email/loops.ts @@ -1,6 +1,7 @@ import { env } from "cloudflare:workers"; const LOOPS_TRANSACTIONAL_URL = "https://app.loops.so/api/v1/transactional"; +const LOOPS_CONTACT_UPDATE_URL = "https://app.loops.so/api/v1/contacts/update"; function getRequiredEnv(name: string) { const value: unknown = Reflect.get(env, name); @@ -67,6 +68,77 @@ async function sendLoopsTransactionalEmail({ ); } +function getOptionalEnv(name: string) { + const value: unknown = Reflect.get(env, name); + const trimmed = typeof value === "string" ? value.trim() : ""; + + return trimmed || null; +} + +function getContactNameParts(name: string | null | undefined) { + const trimmedName = name?.trim(); + + if (!trimmedName) { + return {}; + } + + const [firstName, ...lastNameParts] = trimmedName.split(/\s+/); + const lastName = lastNameParts.join(" "); + + return { + firstName, + ...(lastName ? { lastName } : {}), + }; +} + +export async function upsertHostedSignupContact({ + userId, + email, + name, +}: { + userId: string; + email: string; + name?: string | null; +}) { + const apiKey = getOptionalEnv("LOOPS_API_KEY"); + + if (!apiKey) { + console.warn( + "Skipping Loops signup contact sync: LOOPS_API_KEY is not set", + ); + return; + } + + const response = await fetch(LOOPS_CONTACT_UPDATE_URL, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + email, + userId, + source: "openseo-signup", + userGroup: "app-user", + ...getContactNameParts(name), + }), + }); + + if (response.ok) { + return; + } + + const errorPayload = await response.json().catch(() => null); + console.error("Loops signup contact sync error:", { + status: response.status, + email, + userId, + errorPayload, + }); + + throw new Error(`Failed to sync Loops signup contact (${response.status})`); +} + export async function sendHostedVerificationEmail({ email, confirmationUrl,