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; 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; 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 ): Promise { 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); }