feat(webapp): sync new orgs + users to Attio CRM on signup (#3896)

Pushes new organizations and users into the Attio CRM at signup time,
for Customer Success (TRI-10431).

- Orgs → Attio `workspaces`, users → Attio `users`, keyed on Attio's
built-in unique `workspace_id` / `user_id` so writes are idempotent
upserts.
- Runs on the common Redis worker (not inline), so a slow or unavailable
Attio never blocks the signup path; failures retry (3 attempts).
- Hooks: user-created (alongside the existing Loops call) and
org-created (`createOrganization`).
- Gated behind `ATTIO_API_KEY`, no key means the sync is skipped
entirely, so OSS / self-hosted installs are unaffected.

Only creation is covered here (the record "shell"); spend, runs, plan
changes, churn, and role/relationship linking are populated by the
scheduled full sync, tracked separately.

**Deploy note:** requires an Attio API key set as `ATTIO_API_KEY` in the
webapp env, with scopes **Records (read-write)** + **Object
Configuration (read)**, the assert/upsert endpoint reads object config
to resolve the matching attribute. Without the key the sync no-ops.

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Iss
2026-06-18 14:46:37 +01:00
committed by GitHub
parent 3fdfe214ed
commit c97d246197
5 changed files with 194 additions and 19 deletions
+12 -19
View File
@@ -8,18 +8,13 @@ import { isValidDuration } from "./services/realtime/duration.server";
// `z.string()` constrained to a `parseDuration`-parseable string (e.g.
// `7d`, `1h`). Validated at boot so a typo'd duration fails fast.
function durationString() {
return z
.string()
.refine(isValidDuration, "must be a duration like 7d, 30d, 365d, 1h, 1y");
return z.string().refine(isValidDuration, "must be a duration like 7d, 30d, 365d, 1h, 1y");
}
// Parses a CSV of machine preset names (e.g. "small-1x,small-2x") into a
// non-empty array of MachinePresetName. Used by COMPUTE_TEMPLATE_MACHINE_PRESETS
// and its _REQUIRED variant. Adds zod issues for empty input or unknown names.
const parseMachinePresetCsv = (
raw: string,
ctx: z.RefinementCtx
): MachinePresetName[] => {
const parseMachinePresetCsv = (raw: string, ctx: z.RefinementCtx): MachinePresetName[] => {
const names = raw
.split(",")
.map((s) => s.trim())
@@ -521,10 +516,7 @@ const EnvironmentSchema = z
.string()
.optional()
.transform((v, ctx) =>
parseMachinePresetCsv(
v ?? process.env.COMPUTE_TEMPLATE_MACHINE_PRESETS ?? "small-1x",
ctx
)
parseMachinePresetCsv(v ?? process.env.COMPUTE_TEMPLATE_MACHINE_PRESETS ?? "small-1x", ctx)
),
DEPLOY_IMAGE_PLATFORM: z.string().default("linux/amd64"),
@@ -696,6 +688,7 @@ const EnvironmentSchema = z
ALERT_RATE_LIMITER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
LOOPS_API_KEY: z.string().optional(),
ATTIO_API_KEY: z.string().optional(),
MARQS_DISABLE_REBALANCING: BoolEnv.default(false),
MARQS_VISIBILITY_TIMEOUT_MS: z.coerce
.number()
@@ -1186,7 +1179,9 @@ const EnvironmentSchema = z
// setting this to "1" while `TRIGGER_MOLLIFIER_ENABLED` is "0" is a
// no-op because the gate-side singleton refuses to construct a buffer
// when the system is off.
TRIGGER_MOLLIFIER_DRAINER_ENABLED: z.string().default(process.env.TRIGGER_MOLLIFIER_ENABLED ?? "0"),
TRIGGER_MOLLIFIER_DRAINER_ENABLED: z
.string()
.default(process.env.TRIGGER_MOLLIFIER_ENABLED ?? "0"),
TRIGGER_MOLLIFIER_SHADOW_MODE: z.string().default("0"),
TRIGGER_MOLLIFIER_REDIS_HOST: z
.string()
@@ -1196,7 +1191,7 @@ const EnvironmentSchema = z
.number()
.optional()
.transform(
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined),
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
),
TRIGGER_MOLLIFIER_REDIS_USERNAME: z
.string()
@@ -1206,7 +1201,9 @@ const EnvironmentSchema = z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_PASSWORD),
TRIGGER_MOLLIFIER_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"),
TRIGGER_MOLLIFIER_REDIS_TLS_DISABLED: z
.string()
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
TRIGGER_MOLLIFIER_TRIP_WINDOW_MS: z.coerce.number().int().positive().default(200),
TRIGGER_MOLLIFIER_TRIP_THRESHOLD: z.coerce.number().int().positive().default(100),
TRIGGER_MOLLIFIER_HOLD_MS: z.coerce.number().int().positive().default(500),
@@ -1270,11 +1267,7 @@ const EnvironmentSchema = z
// (retrieve, trace) have a safety net while PG replica lag settles.
TRIGGER_MOLLIFIER_ACK_GRACE_TTL_SECONDS: z.coerce.number().int().positive().default(30),
// ioredis per-request retry limit on the buffer's Redis client.
TRIGGER_MOLLIFIER_REDIS_MAX_RETRIES_PER_REQUEST: z.coerce
.number()
.int()
.positive()
.default(20),
TRIGGER_MOLLIFIER_REDIS_MAX_RETRIES_PER_REQUEST: z.coerce.number().int().positive().default(20),
// ioredis reconnect backoff envelope for the buffer client: the base
// grows by `STEP_MS` per attempt, capped at `MAX_MS`, then equal-jittered.
TRIGGER_MOLLIFIER_REDIS_RECONNECT_STEP_MS: z.coerce.number().int().positive().default(50),
@@ -14,6 +14,7 @@ import { env } from "~/env.server";
import { featuresForUrl } from "~/features.server";
import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server";
import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
import { enqueueAttioWorkspaceSync } from "~/services/attio.server";
export type { Organization };
const nanoid = customAlphabet("1234567890abcdef", 4);
@@ -104,6 +105,16 @@ export async function createOrganization(
},
});
// Fire-and-forget; never blocks org creation.
void enqueueAttioWorkspaceSync({
orgId: organization.id,
title: organization.title,
slug: organization.slug,
companySize: organization.companySize,
createdAt: organization.createdAt,
adminUserId: userId,
});
return { ...organization };
}
+136
View File
@@ -0,0 +1,136 @@
import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "./logger.server";
// Syncs new orgs/users into Attio (workspaces/users objects) at signup, via the
// common worker so a slow Attio never blocks signup. Ongoing field updates are
// handled by the scheduled sync, not here. No-op without ATTIO_API_KEY.
const ATTIO_API = "https://api.attio.com/v2";
const IS_TEST = env.APP_ENV !== "production";
export const AttioWorkspaceSyncSchema = z.object({
orgId: z.string(),
title: z.string(),
slug: z.string(),
companySize: z.string().nullish(),
createdAt: z.coerce.date(),
adminUserId: z.string(),
});
export type AttioWorkspaceSync = z.infer<typeof AttioWorkspaceSyncSchema>;
export const AttioUserSyncSchema = z.object({
userId: z.string(),
email: z.string(),
referralSource: z.string().nullish(),
marketingEmails: z.boolean(),
createdAt: z.coerce.date(),
});
export type AttioUserSync = z.infer<typeof AttioUserSyncSchema>;
class AttioClient {
constructor(private readonly apiKey: string) {}
// Create-or-update by unique attribute; returns the record id. Throws on failure so the worker retries.
async #assert(object: string, matchingAttribute: string, values: Record<string, unknown>): Promise<string> {
const url = `${ATTIO_API}/objects/${object}/records?matching_attribute=${matchingAttribute}`;
const response = await fetch(url, {
method: "PUT",
headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({ data: { values } }),
});
if (!response.ok) {
const body = await response.text();
logger.error("Attio assert failed", { object, matchingAttribute, status: response.status, body });
throw new Error(`Attio assert ${object} failed with status ${response.status}`);
}
const recordId = ((await response.json()) as any).data?.id?.record_id;
if (typeof recordId !== "string") {
throw new Error(`Attio assert ${object}: response missing data.id.record_id`);
}
return recordId;
}
async upsertWorkspace(payload: AttioWorkspaceSync, emailDomain?: string) {
// The creating user is an admin of the new org — set their role and link them to the workspace.
const adminRecordId = await this.#assert("users", "user_id", {
user_id: payload.adminUserId,
role: "Admin",
is_test: IS_TEST,
});
await this.#assert("workspaces", "workspace_id", {
workspace_id: payload.orgId,
name: payload.title,
org_slug: payload.slug,
company_size: payload.companySize ?? undefined,
email_domain: emailDomain,
signup_date: toDate(payload.createdAt),
plan: "Free",
account_status: "Active",
is_test: IS_TEST,
users: [{ target_object: "users", target_record_id: adminRecordId }],
});
}
async upsertUser(payload: AttioUserSync) {
await this.#assert("users", "user_id", {
user_id: payload.userId,
primary_email_address: payload.email,
marketing_opt_in: payload.marketingEmails,
referral_source: payload.referralSource ?? undefined,
signup_date: toDate(payload.createdAt),
is_test: IS_TEST,
});
}
}
// Attio `date` attributes want a bare YYYY-MM-DD value.
function toDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
// Domain from an email; the cloud-side matcher normalizes it further.
function domainFromEmail(email: string | undefined): string | undefined {
return email?.split("@")[1]?.toLowerCase().trim() || undefined;
}
export const attioClient = env.ATTIO_API_KEY ? new AttioClient(env.ATTIO_API_KEY) : null;
export async function enqueueAttioWorkspaceSync(payload: AttioWorkspaceSync) {
if (!attioClient) return;
try {
// Lazy import to avoid a circular dependency with commonWorker (which imports this module's schemas).
const { commonWorker } = await import("~/v3/commonWorker.server");
await commonWorker.enqueue({ id: `attio:workspace:${payload.orgId}`, job: "attio.syncWorkspace", payload });
} catch (error) {
logger.error("Failed to enqueue Attio workspace sync", { orgId: payload.orgId, error });
}
}
export async function enqueueAttioUserSync(payload: AttioUserSync) {
if (!attioClient) return;
try {
const { commonWorker } = await import("~/v3/commonWorker.server");
await commonWorker.enqueue({ id: `attio:user:${payload.userId}`, job: "attio.syncUser", payload });
} catch (error) {
logger.error("Failed to enqueue Attio user sync", { userId: payload.userId, error });
}
}
export async function runAttioWorkspaceSync(payload: AttioWorkspaceSync) {
if (!attioClient) return;
const admin = await prisma.user.findFirst({
where: { id: payload.adminUserId },
select: { email: true },
});
await attioClient.upsertWorkspace(payload, domainFromEmail(admin?.email));
}
export async function runAttioUserSync(payload: AttioUserSync) {
if (!attioClient) return;
await attioClient.upsertUser(payload);
}
@@ -5,6 +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 { enqueueAttioUserSync } from "./attio.server";
import { loopsClient } from "./loops.server";
type Options = {
@@ -74,6 +75,14 @@ class Telemetry {
email: user.email,
name: user.name,
});
enqueueAttioUserSync({
userId: user.id,
email: user.email,
referralSource: referralSource ?? user.referralSource,
marketingEmails: user.marketingEmails,
createdAt: user.createdAt,
});
}
},
};
+26
View File
@@ -5,6 +5,12 @@ import { z } from "zod";
import { env } from "~/env.server";
import { RunEngineBatchTriggerService } from "~/runEngine/services/batchTrigger.server";
import { sendEmail } from "~/services/email.server";
import {
AttioUserSyncSchema,
AttioWorkspaceSyncSchema,
runAttioUserSync,
runAttioWorkspaceSync,
} from "~/services/attio.server";
import { logger } from "~/services/logger.server";
import { singleton } from "~/utils/singleton";
import { DeliverAlertService } from "./services/alerts/deliverAlert.server";
@@ -46,6 +52,20 @@ function initializeWorker() {
maxAttempts: 3,
},
},
"attio.syncWorkspace": {
schema: AttioWorkspaceSyncSchema,
visibilityTimeoutMs: 30_000,
retry: {
maxAttempts: 3,
},
},
"attio.syncUser": {
schema: AttioUserSyncSchema,
visibilityTimeoutMs: 30_000,
retry: {
maxAttempts: 3,
},
},
"v3.resumeBatchRun": {
schema: z.object({
batchRunId: z.string(),
@@ -213,6 +233,12 @@ function initializeWorker() {
scheduleEmail: async ({ payload }) => {
await sendEmail(payload);
},
"attio.syncWorkspace": async ({ payload }) => {
await runAttioWorkspaceSync(payload);
},
"attio.syncUser": async ({ payload }) => {
await runAttioUserSync(payload);
},
"v3.resumeBatchRun": async ({ payload }) => {
const service = new ResumeBatchRunService();
await service.call(payload.batchRunId);