Skip to content

Commit f294229

Browse files
isshaddadTrigger.dev RepoOps
authored andcommitted
fix(webapp): always write the Attio workspace for new orgs
Always write the Attio workspace for a new org, even when its admin user can't be synced. The workspace sync asserted the admin as an Attio user first. When the user didn't exist yet, that create was missing the required email and failed on every retry, so the workspace was never written. The sync now creates a missing user with the admin's email, writes the workspace without the user link if Attio still rejects the user, and retries transient failures over a few minutes instead of a few seconds. Mono-RevId: 69898eb2976a14e0e6ed14d65add3f70244f1bdb
1 parent 5924564 commit f294229

4 files changed

Lines changed: 358 additions & 94 deletions

File tree

‎apps/webapp/app/services/attio.server.ts‎

Lines changed: 5 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
import { z } from "zod";
22
import { prisma } from "~/db.server";
33
import { env } from "~/env.server";
4+
import { AttioClient, syncAttioWorkspace } from "./attioClient.server";
45
import { logger } from "./logger.server";
56

67
// Syncs new orgs/users into Attio (workspaces/users objects) at signup, via the
78
// common worker so a slow Attio never blocks signup. Ongoing field updates are
89
// handled by the scheduled sync, not here. No-op without ATTIO_API_KEY.
910

10-
const ATTIO_API = "https://api.attio.com/v2";
11-
const IS_TEST = env.APP_ENV !== "production";
12-
1311
export const AttioWorkspaceSyncSchema = z.object({
1412
orgId: z.string(),
1513
title: z.string(),
@@ -29,90 +27,9 @@ export const AttioUserSyncSchema = z.object({
2927
});
3028
export type AttioUserSync = z.infer<typeof AttioUserSyncSchema>;
3129

32-
class AttioClient {
33-
constructor(private readonly apiKey: string) {}
34-
35-
// Create-or-update by unique attribute; returns the record id. Throws on failure so the worker retries.
36-
async #assert(
37-
object: string,
38-
matchingAttribute: string,
39-
values: Record<string, unknown>
40-
): Promise<string> {
41-
const url = `${ATTIO_API}/objects/${object}/records?matching_attribute=${matchingAttribute}`;
42-
const response = await fetch(url, {
43-
method: "PUT",
44-
headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" },
45-
body: JSON.stringify({ data: { values } }),
46-
});
47-
48-
if (!response.ok) {
49-
const body = await response.text();
50-
// 5xx/429 are transient (the worker retries); warn + tag so they don't page Sentry. Real 4xx stay error.
51-
const transient = response.status >= 500 || response.status === 429;
52-
const fields = { object, matchingAttribute, status: response.status, body };
53-
if (transient) {
54-
logger.warn("Attio assert failed", fields);
55-
} else {
56-
logger.error("Attio assert failed", fields);
57-
}
58-
const message = `Attio assert ${object} failed with status ${response.status}`;
59-
throw transient
60-
? Object.assign(new Error(message), { logLevel: "warn" as const })
61-
: new Error(message);
62-
}
63-
64-
const recordId = ((await response.json()) as any).data?.id?.record_id;
65-
if (typeof recordId !== "string") {
66-
throw new Error(`Attio assert ${object}: response missing data.id.record_id`);
67-
}
68-
return recordId;
69-
}
70-
71-
async upsertWorkspace(payload: AttioWorkspaceSync, emailDomain?: string) {
72-
// The creating user is an admin of the new org — set their role and link them to the workspace.
73-
const adminRecordId = await this.#assert("users", "user_id", {
74-
user_id: payload.adminUserId,
75-
role: "Admin",
76-
is_test: IS_TEST,
77-
});
78-
79-
await this.#assert("workspaces", "workspace_id", {
80-
workspace_id: payload.orgId,
81-
name: payload.title,
82-
org_slug: payload.slug,
83-
company_size: payload.companySize ?? undefined,
84-
email_domain: emailDomain,
85-
signup_date: toDate(payload.createdAt),
86-
plan: "Free",
87-
account_status: "Active",
88-
is_test: IS_TEST,
89-
users: [{ target_object: "users", target_record_id: adminRecordId }],
90-
});
91-
}
92-
93-
async upsertUser(payload: AttioUserSync) {
94-
await this.#assert("users", "user_id", {
95-
user_id: payload.userId,
96-
primary_email_address: payload.email,
97-
marketing_opt_in: payload.marketingEmails,
98-
referral_source: payload.referralSource ?? undefined,
99-
signup_date: toDate(payload.createdAt),
100-
is_test: IS_TEST,
101-
});
102-
}
103-
}
104-
105-
// Attio `date` attributes want a bare YYYY-MM-DD value.
106-
function toDate(date: Date): string {
107-
return date.toISOString().slice(0, 10);
108-
}
109-
110-
// Domain from an email; the cloud-side matcher normalizes it further.
111-
function domainFromEmail(email: string | undefined): string | undefined {
112-
return email?.split("@")[1]?.toLowerCase().trim() || undefined;
113-
}
114-
115-
const attioClient = env.ATTIO_API_KEY ? new AttioClient(env.ATTIO_API_KEY) : null;
30+
const attioClient = env.ATTIO_API_KEY
31+
? new AttioClient({ apiKey: env.ATTIO_API_KEY, isTest: env.APP_ENV !== "production" })
32+
: null;
11633

11734
export async function enqueueAttioWorkspaceSync(payload: AttioWorkspaceSync) {
11835
if (!attioClient) return;
@@ -145,11 +62,7 @@ export async function enqueueAttioUserSync(payload: AttioUserSync) {
14562

14663
export async function runAttioWorkspaceSync(payload: AttioWorkspaceSync) {
14764
if (!attioClient) return;
148-
const admin = await prisma.user.findFirst({
149-
where: { id: payload.adminUserId },
150-
select: { email: true },
151-
});
152-
await attioClient.upsertWorkspace(payload, domainFromEmail(admin?.email));
65+
await syncAttioWorkspace(payload, attioClient, prisma);
15366
}
15467

15568
export async function runAttioUserSync(payload: AttioUserSync) {
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import type { PrismaClientOrTransaction } from "~/db.server";
2+
import type { AttioUserSync, AttioWorkspaceSync } from "./attio.server";
3+
import { logger } from "./logger.server";
4+
5+
const ATTIO_API = "https://api.attio.com/v2";
6+
7+
class AttioRequestError extends Error {
8+
// 5xx/429 are transient (the worker retries); tagged warn so they don't page Sentry.
9+
readonly transient: boolean;
10+
readonly logLevel?: "warn";
11+
12+
constructor(
13+
object: string,
14+
readonly status: number,
15+
readonly code: string | undefined,
16+
body: string
17+
) {
18+
super(`Attio assert ${object} failed with status ${status}: ${body.slice(0, 500)}`);
19+
this.transient = status >= 500 || status === 429;
20+
if (this.transient) this.logLevel = "warn";
21+
}
22+
}
23+
24+
export type AttioClientOptions = {
25+
apiKey: string;
26+
// Marks records written from non-production environments.
27+
isTest: boolean;
28+
baseUrl?: string;
29+
};
30+
31+
export class AttioClient {
32+
constructor(private readonly options: AttioClientOptions) {}
33+
34+
// Create-or-update by unique attribute; returns the record id. Throws on failure so the worker retries.
35+
async #assert(
36+
object: string,
37+
matchingAttribute: string,
38+
values: Record<string, unknown>
39+
): Promise<string> {
40+
const baseUrl = this.options.baseUrl ?? ATTIO_API;
41+
const url = `${baseUrl}/objects/${object}/records?matching_attribute=${matchingAttribute}`;
42+
const response = await fetch(url, {
43+
method: "PUT",
44+
headers: {
45+
Authorization: `Bearer ${this.options.apiKey}`,
46+
"Content-Type": "application/json",
47+
},
48+
body: JSON.stringify({ data: { values } }),
49+
});
50+
51+
if (!response.ok) {
52+
const body = await response.text();
53+
throw new AttioRequestError(object, response.status, errorCode(body), body);
54+
}
55+
56+
const recordId = ((await response.json()) as any).data?.id?.record_id;
57+
if (typeof recordId !== "string") {
58+
throw new Error(`Attio assert ${object}: response missing data.id.record_id`);
59+
}
60+
return recordId;
61+
}
62+
63+
// The org's creator as an Attio admin user. Returns undefined when Attio rejects the user,
64+
// so the workspace is still written; transient failures throw so the job retries.
65+
async #assertAdminUser(userId: string, email: string | undefined) {
66+
const values = { user_id: userId, role: "Admin", is_test: this.options.isTest };
67+
try {
68+
return await this.#assert("users", "user_id", values);
69+
} catch (error) {
70+
if (!(error instanceof AttioRequestError) || error.transient) throw error;
71+
let rejection = error;
72+
73+
// No Attio user yet, and creating one requires the email.
74+
if (rejection.code === "missing_value" && email) {
75+
try {
76+
return await this.#assert("users", "user_id", {
77+
...values,
78+
primary_email_address: email,
79+
});
80+
} catch (createError) {
81+
if (!(createError instanceof AttioRequestError) || createError.transient) {
82+
throw createError;
83+
}
84+
rejection = createError;
85+
}
86+
87+
// A concurrent user sync may have created the user meanwhile.
88+
if (rejection.code === "uniqueness_conflict") {
89+
try {
90+
return await this.#assert("users", "user_id", values);
91+
} catch (retryError) {
92+
if (!(retryError instanceof AttioRequestError) || retryError.transient) {
93+
throw retryError;
94+
}
95+
}
96+
}
97+
}
98+
99+
logger.warn("Attio admin user not synced, writing the workspace without it", {
100+
userId,
101+
error: rejection.message,
102+
});
103+
return undefined;
104+
}
105+
}
106+
107+
async upsertWorkspace(payload: AttioWorkspaceSync, adminEmail?: string) {
108+
const adminRecordId = await this.#assertAdminUser(payload.adminUserId, adminEmail);
109+
110+
await this.#assert("workspaces", "workspace_id", {
111+
workspace_id: payload.orgId,
112+
name: payload.title,
113+
org_slug: payload.slug,
114+
company_size: payload.companySize ?? undefined,
115+
email_domain: domainFromEmail(adminEmail),
116+
signup_date: toDate(payload.createdAt),
117+
plan: "Free",
118+
account_status: "Active",
119+
is_test: this.options.isTest,
120+
users: adminRecordId
121+
? [{ target_object: "users", target_record_id: adminRecordId }]
122+
: undefined,
123+
});
124+
}
125+
126+
async upsertUser(payload: AttioUserSync) {
127+
await this.#assert("users", "user_id", {
128+
user_id: payload.userId,
129+
primary_email_address: payload.email,
130+
marketing_opt_in: payload.marketingEmails,
131+
referral_source: payload.referralSource ?? undefined,
132+
signup_date: toDate(payload.createdAt),
133+
is_test: this.options.isTest,
134+
});
135+
}
136+
}
137+
138+
// Attio `date` attributes want a bare YYYY-MM-DD value.
139+
function toDate(date: Date): string {
140+
return date.toISOString().slice(0, 10);
141+
}
142+
143+
// Attio error bodies look like {"code":"missing_value",...}.
144+
function errorCode(body: string): string | undefined {
145+
try {
146+
const code = (JSON.parse(body) as { code?: unknown } | null)?.code;
147+
return typeof code === "string" ? code : undefined;
148+
} catch {
149+
return undefined;
150+
}
151+
}
152+
153+
// Domain from an email; the cloud-side matcher normalizes it further.
154+
function domainFromEmail(email: string | undefined): string | undefined {
155+
return email?.split("@")[1]?.toLowerCase().trim() || undefined;
156+
}
157+
158+
// Writes the org's workspace, looking up its admin's email for the user record and domain.
159+
export async function syncAttioWorkspace(
160+
payload: AttioWorkspaceSync,
161+
client: AttioClient,
162+
db: PrismaClientOrTransaction
163+
) {
164+
const admin = await db.user.findFirst({
165+
where: { id: payload.adminUserId },
166+
select: { email: true },
167+
});
168+
await client.upsertWorkspace(payload, admin?.email);
169+
}

‎apps/webapp/app/v3/commonWorker.server.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,14 +113,15 @@ function initializeWorker() {
113113
schema: AttioWorkspaceSyncSchema,
114114
visibilityTimeoutMs: 30_000,
115115
retry: {
116-
maxAttempts: 3,
116+
// 2-4 min of backoff, so a short Attio outage or rate limit doesn't dead-letter the job.
117+
maxAttempts: 8,
117118
},
118119
},
119120
"attio.syncUser": {
120121
schema: AttioUserSyncSchema,
121122
visibilityTimeoutMs: 30_000,
122123
retry: {
123-
maxAttempts: 3,
124+
maxAttempts: 8,
124125
},
125126
},
126127
"membership.provisionDevEnvironments": {

0 commit comments

Comments
 (0)