2026-05-11 13:22:18 -04:00
|
|
|
/**
|
|
|
|
|
* 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:
|
2026-05-11 14:10:45 -04:00
|
|
|
* GOOGLE_SECRET_NAME - Secrets Manager name for Google service account JSON key
|
|
|
|
|
* FRONT_SECRET_NAME - Secrets Manager name for Front API token
|
2026-05-11 13:22:18 -04:00
|
|
|
* GOOGLE_ADMIN_EMAIL - Email of a Google Workspace admin to impersonate
|
|
|
|
|
* 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
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-05-11 14:39:57 -04:00
|
|
|
async function listGoogleUsersInOU(accessToken, orgUnitPath) {
|
2026-05-11 13:22:18 -04:00
|
|
|
const users = [];
|
|
|
|
|
let pageToken = null;
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
const params = new URLSearchParams({
|
2026-05-11 14:39:57 -04:00
|
|
|
customer: "my_customer",
|
2026-05-11 13:22:18 -04:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 14:39:57 -04:00
|
|
|
async function listGoogleUsers(accessToken, orgUnits) {
|
2026-05-11 13:22:18 -04:00
|
|
|
const allUsers = [];
|
|
|
|
|
const seen = new Set();
|
|
|
|
|
|
|
|
|
|
for (const ou of orgUnits) {
|
|
|
|
|
console.log(`Fetching users from OU: ${ou}`);
|
2026-05-11 14:39:57 -04:00
|
|
|
const users = await listGoogleUsersInOU(accessToken, ou);
|
2026-05-11 13:22:18 -04:00
|
|
|
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) {
|
2026-05-11 13:44:23 -04:00
|
|
|
const primaryOrg = googleUser.organizations.find((o) => o.primary) || googleUser.organizations[0];
|
|
|
|
|
jobTitle = primaryOrg.title || "";
|
2026-05-11 13:22:18 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let phone = "";
|
|
|
|
|
if (googleUser.phones && googleUser.phones.length > 0) {
|
2026-05-11 13:57:30 -04:00
|
|
|
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);
|
2026-05-11 14:11:39 -04:00
|
|
|
phone = (workPhone || primaryPhone || {}).value || "";
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 13:22:18 -04:00
|
|
|
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)}`;
|
2026-05-11 13:44:23 -04:00
|
|
|
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 };
|
2026-05-11 13:22:18 -04:00
|
|
|
|
2026-05-11 13:44:23 -04:00
|
|
|
const patchRes = await fetch(url, {
|
2026-05-11 13:22:18 -04:00
|
|
|
method: "PATCH",
|
2026-05-11 13:44:23 -04:00
|
|
|
headers,
|
|
|
|
|
body: JSON.stringify({ custom_fields: merged }),
|
2026-05-11 13:22:18 -04:00
|
|
|
});
|
|
|
|
|
|
2026-05-11 13:44:23 -04:00
|
|
|
if (patchRes.status === 204) return { status: "updated" };
|
2026-05-11 13:22:18 -04:00
|
|
|
|
2026-05-11 13:44:23 -04:00
|
|
|
const text = await patchRes.text();
|
|
|
|
|
return { status: "error", code: patchRes.status, message: text };
|
2026-05-11 13:22:18 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Handler
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
exports.handler = async (event) => {
|
|
|
|
|
console.log("Starting Front ← Google Directory sync");
|
|
|
|
|
|
|
|
|
|
// 1. Fetch secrets
|
|
|
|
|
const [googleSecretRes, frontSecretRes] = await Promise.all([
|
|
|
|
|
sm.send(
|
2026-05-11 14:10:45 -04:00
|
|
|
new GetSecretValueCommand({ SecretId: process.env.GOOGLE_SECRET_NAME })
|
2026-05-11 13:22:18 -04:00
|
|
|
),
|
|
|
|
|
sm.send(
|
2026-05-11 14:10:45 -04:00
|
|
|
new GetSecretValueCommand({ SecretId: process.env.FRONT_SECRET_NAME })
|
2026-05-11 13:22:18 -04:00
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
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(", ")}`);
|
|
|
|
|
|
2026-05-11 14:39:57 -04:00
|
|
|
const googleUsers = await listGoogleUsers(accessToken, orgUnits);
|
2026-05-11 13:22:18 -04:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
};
|