/** * google-user-sync Lambda * * Pulls user profiles from Google Workspace Admin Directory API * and syncs job title + phone to Front teammate custom fields. * * Environment variables: * GOOGLE_SECRET_NAME - Secrets Manager name for Google service account JSON key * FRONT_SECRET_NAME - Secrets Manager name for Front API token * GOOGLE_ADMIN_EMAIL - Email of a Google Workspace admin to impersonate * GOOGLE_DOMAIN - Domain to list users for (e.g. seahaven.com) * GOOGLE_OUS - Comma-separated org unit paths to sync (e.g. /Office/Scheduling,/Office/Operations) */ const { SecretsManagerClient, GetSecretValueCommand, } = require("@aws-sdk/client-secrets-manager"); const crypto = require("crypto"); const sm = new SecretsManagerClient({ region: "us-east-1" }); // --------------------------------------------------------------------------- // Google Auth (JWT → Access Token using service account) // --------------------------------------------------------------------------- function base64url(buf) { return Buffer.from(buf) .toString("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); } async function getGoogleAccessToken(serviceAccountKey, adminEmail) { const now = Math.floor(Date.now() / 1000); const header = { alg: "RS256", typ: "JWT" }; const payload = { iss: serviceAccountKey.client_email, sub: adminEmail, scope: "https://www.googleapis.com/auth/admin.directory.user.readonly", aud: "https://oauth2.googleapis.com/token", iat: now, exp: now + 3600, }; const segments = [ base64url(JSON.stringify(header)), base64url(JSON.stringify(payload)), ]; const signingInput = segments.join("."); const sign = crypto.createSign("RSA-SHA256"); sign.update(signingInput); const signature = sign.sign(serviceAccountKey.private_key); const jwt = signingInput + "." + base64url(signature); const res = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion: jwt, }), }); if (!res.ok) { const text = await res.text(); throw new Error(`Google token exchange failed (${res.status}): ${text}`); } const data = await res.json(); return data.access_token; } // --------------------------------------------------------------------------- // Google Admin Directory: list users for a specific OU // --------------------------------------------------------------------------- async function listGoogleUsersInOU(accessToken, domain, orgUnitPath) { const users = []; let pageToken = null; do { const params = new URLSearchParams({ domain, maxResults: "500", projection: "full", orderBy: "email", query: `orgUnitPath='${orgUnitPath}'`, }); if (pageToken) params.set("pageToken", pageToken); const res = await fetch( `https://admin.googleapis.com/admin/directory/v1/users?${params}`, { headers: { Authorization: `Bearer ${accessToken}` } } ); if (!res.ok) { const text = await res.text(); throw new Error( `Google Directory API error for OU "${orgUnitPath}" (${res.status}): ${text}` ); } const data = await res.json(); if (data.users) users.push(...data.users); pageToken = data.nextPageToken || null; } while (pageToken); return users; } async function listGoogleUsers(accessToken, domain, orgUnits) { const allUsers = []; const seen = new Set(); for (const ou of orgUnits) { console.log(`Fetching users from OU: ${ou}`); const users = await listGoogleUsersInOU(accessToken, domain, ou); console.log(` Found ${users.length} users in ${ou}`); for (const user of users) { if (!seen.has(user.primaryEmail)) { seen.add(user.primaryEmail); allUsers.push(user); } } } return allUsers; } // --------------------------------------------------------------------------- // Extract fields from a Google user object // --------------------------------------------------------------------------- function extractUserFields(googleUser) { const email = googleUser.primaryEmail; let jobTitle = ""; if (googleUser.organizations && googleUser.organizations.length > 0) { const primaryOrg = googleUser.organizations.find((o) => o.primary) || googleUser.organizations[0]; jobTitle = primaryOrg.title || ""; } let phone = ""; if (googleUser.phones && googleUser.phones.length > 0) { const workPhone = googleUser.phones.find((p) => p.type === "work" && p.value); const primaryPhone = googleUser.phones.find((p) => p.primary && p.value) || googleUser.phones.find((p) => p.value); phone = (workPhone && workPhone.value) || primaryPhone.value || ""; return { email, jobTitle, phone }; } // --------------------------------------------------------------------------- // Front API: update teammate custom fields // --------------------------------------------------------------------------- async function updateFrontTeammate(frontToken, email, customFields) { const url = `https://api2.frontapp.com/teammates/alt:email:${encodeURIComponent(email)}`; const headers = { Authorization: `Bearer ${frontToken}`, "Content-Type": "application/json", Accept: "application/json", }; const getRes = await fetch(url, { headers }); if (getRes.status === 404) return { status: "not_in_front" }; if (!getRes.ok) { const text = await getRes.text(); return { status: "error", code: getRes.status, message: text }; } const teammate = await getRes.json(); const merged = { ...teammate.custom_fields, ...customFields }; const patchRes = await fetch(url, { method: "PATCH", headers, body: JSON.stringify({ custom_fields: merged }), }); if (patchRes.status === 204) return { status: "updated" }; const text = await patchRes.text(); return { status: "error", code: patchRes.status, message: text }; } // --------------------------------------------------------------------------- // Handler // --------------------------------------------------------------------------- exports.handler = async (event) => { console.log("Starting Front ← Google Directory sync"); // 1. Fetch secrets const [googleSecretRes, frontSecretRes] = await Promise.all([ sm.send( new GetSecretValueCommand({ SecretId: process.env.GOOGLE_SECRET_NAME }) ), sm.send( new GetSecretValueCommand({ SecretId: process.env.FRONT_SECRET_NAME }) ), ]); const googleKey = JSON.parse(googleSecretRes.SecretString); const frontToken = frontSecretRes.SecretString; // 2. Get Google access token const accessToken = await getGoogleAccessToken( googleKey, process.env.GOOGLE_ADMIN_EMAIL ); console.log("Obtained Google access token"); // 3. List Google Workspace users from specified OUs const orgUnits = process.env.GOOGLE_OUS .split(",") .map((ou) => ou.trim()) .filter(Boolean); console.log(`Syncing OUs: ${orgUnits.join(", ")}`); const googleUsers = await listGoogleUsers( accessToken, process.env.GOOGLE_DOMAIN, orgUnits ); console.log(`Found ${googleUsers.length} total users across ${orgUnits.length} OUs`); // 4. Sync each user to Front const summary = { updated: 0, notInFront: 0, noData: 0, errors: 0, errorDetails: [], }; for (const googleUser of googleUsers) { const { email, jobTitle, phone } = extractUserFields(googleUser); // Skip users with no title AND no phone — nothing to sync if (!jobTitle && !phone) { console.log(`Skipping ${email} — no title or phone in Google`); summary.noData++; continue; } const customFields = {}; if (jobTitle) customFields["Job Title"] = jobTitle; if (phone) customFields["Phone"] = phone; try { const result = await updateFrontTeammate(frontToken, email, customFields); if (result.status === "updated") { console.log(`Updated ${email}: ${JSON.stringify(customFields)}`); summary.updated++; } else if (result.status === "not_in_front") { console.log(`Skipped ${email} — not a Front teammate`); summary.notInFront++; } else { console.error(`Error updating ${email}: ${result.code} ${result.message}`); summary.errors++; summary.errorDetails.push({ email, message: `${result.code}: ${result.message}`, }); } } catch (err) { console.error(`Exception updating ${email}:`, err); summary.errors++; summary.errorDetails.push({ email, message: err.message }); } // Simple rate-limit courtesy — Front API has rate limits await new Promise((r) => setTimeout(r, 200)); } console.log("Sync summary:", JSON.stringify(summary)); return { statusCode: 200, body: summary, }; };