Figma → Torii via Google Sheets (Enterprise Column Set)

This guide updates the original Google Sheets example to reflect Figma Enterprise exports and a simplified, reliable seat-type–based license model. The upload and sync flow uses Torii’s Files and Services APIs.


Summary: This guide shows how to sync a Figma Enterprise members export from Google Sheets into Torii via a Custom Integration. It includes an updated column structure (supporting Enterprise fields), a resilient Apps Script (dynamic header mapping, robust date handling), an opinionated licensing model that groups users by seat_type (view, dev, collab, full, empty, with a fallback No Seat Type Assigned), and a rule that marks users in the fallback bucket inactive when no usage is present. The script uploads a JSON file to Torii and triggers a custom-integration sync so usage appears in the Licenses view.

Figma reference: For context on viewing members and guests, see Figma’s guide: https://help.figma.com/hc/en-us/articles/4420842863255-View-members-and-guests


What you’ll build

  • A Google Sheet that contains your Figma Enterprise export (CSV → Sheet).

  • An Apps Script that:

    • Reads the header row dynamically (order-agnostic).
    • Normalizes roles and seat types.
    • Calculates most recent usage per product (Figma, FigJam, Slides, Dev Mode).
    • Groups users into licenses by seat_type (with a No Seat Type Assigned fallback).
    • Sets licenseStatus: "inactive" when a user is in the fallback license and has no usage.
    • Uploads JSON to Torii Files and triggers a Custom Integration sync.

Prerequisites

  • Torii API Key with permissions to upload files and trigger custom integration syncs.
  • Torii App ID (the app account you’ll sync).
  • A Figma Enterprise Members CSV export.

Step 1 — Export the Figma members list (Enterprise)

Export the Members list (CSV) from Figma’s Admin console. Enterprise exports commonly include:

  • Identity & Org name, email, role, job_title, division, department, organization, employee_number, manager, 2fa_enabled

  • License/Seat seat_type, paid_seat_date_assigned, scim_billing_model_schema

  • Dates date_added, figma_date_upgraded, figjam_date_upgraded, dev_mode_date_upgraded

  • Usage (per surface)

    • Seen: last_seen_figma, last_seen_dev_mode, last_seen_figjam, last_seen_slides
    • Edit: last_edit_figma, last_edit_figjam, last_edit_slides

The script is header-driven. If the export includes extra columns, they are safely ignored.


Step 2 — Create the Sheet & open Apps Script

  1. Create a new Google Sheet and paste the CSV (keep the header row in row 1).
  2. Open Extensions → Apps Script.
  3. Replace the default file contents with the script in Step 5.
  4. Save and return to the Sheet. You’ll see a Torii menu.

Step 3 — Column structure & mapping

This version is header-driven—no hardcoded column indices. The script locates columns by name (case/spacing insensitive), then maps them internally.

Required headers

  • name, email, role, seat_type

Optional headers (used when present)

  • Identity/org: job_title, division, department, organization, employee_number, manager, 2fa_enabled, scim_billing_model_schema

  • Dates: paid_seat_date_assigned, date_added, figma_date_upgraded, figjam_date_upgraded, dev_mode_date_upgraded

  • Usage:

    • Seen: last_seen_figma, last_seen_dev_mode, last_seen_figjam, last_seen_slides
    • Edit: last_edit_figma, last_edit_figjam, last_edit_slides

If a required header is missing, the Validate Sheet menu item reports it and stops the run.


Step 4 — Seat types, roles, and usage logic

  • Seat types (canonical): view, dev, collab, full, empty

    • Unknown values are logged and used as-is as license names (so you can fix upstream data later).
    • Blank/null seat types go to No Seat Type Assigned.
  • Roles: member, admin, guest, provisional

    • Roles are included as informational; users are not filtered by role.
  • Usage date logic

    • Per product, usage = most recent of:

      • Figma: max(last_seen_figma, last_edit_figma)
      • FigJam: max(last_seen_figjam, last_edit_figjam)
      • Slides: max(last_seen_slides, last_edit_slides)
      • Dev Mode: last_seen_dev_mode
    • Upgrade dates (e.g., *_date_upgraded) do not count as usage.

    • License lastUsedDate = max across all product usages (or omitted when none).

  • Inactive rule (fallback license)

    • If a user lands in No Seat Type Assigned and has no usage, they are added with licenseStatus: "inactive".
    • All other users default to licenseStatus: "active".

Step 5 — Paste-ready Apps Script

In Apps Script, paste the code below, save, then refresh the Sheet. Use the Torii menu to Reconfigure, Validate Sheet, Dry Run, or Sync now.

/*******************************
 * Torii – Google Sheets → Custom Integration
 * Version: 2025-11-18
 * Notes:
 *  - Dynamic header mapping (order-agnostic).
 *  - Robust date handling: compare Date objects; format at output (UTC).
 *  - License bucketing by seat_type with fallback "No Seat Type Assigned".
 *  - Roles included: member, admin, guest, provisional (normalized).
 *  - Optional utilities: Validate Sheet, Dry Run.
 *  - Rule: If license is "No Seat Type Assigned" and no lastUsedDate → licenseStatus = "inactive".
 *******************************/

let API_KEY;
let APP_ID;

function onOpen() {
  const ui = SpreadsheetApp.getUi();
  ui.createMenu('Torii')
    .addItem('Sync now', 'syncNow')
    .addItem('Dry Run (no upload)', 'dryRun')
    .addSeparator()
    .addItem('Validate Sheet', 'validateSheet')
    .addSeparator()
    .addItem('Reconfigure', 'readConfig')
    .addToUi();
}

function readConfig() {
  const ui = SpreadsheetApp.getUi();
  let result = ui.prompt('Please enter Torii API Key');
  if (result.getSelectedButton() == ui.Button.CANCEL) return;
  API_KEY = result.getResponseText().trim();

  result = ui.prompt('Please enter the APP ID');
  if (result.getSelectedButton() == ui.Button.CANCEL) return;
  APP_ID = result.getResponseText().trim();

  const docProperties = PropertiesService.getDocumentProperties();
  docProperties.setProperty('api_key', API_KEY);
  docProperties.setProperty('app_id', APP_ID);

  ui.alert('Configuration saved.');
}

function syncNow() {
  try {
    ensureConfig();
    const file = buildCustomIntegrationFile(); // throws on validation failures
    const fileId = uploadFile(file);
    syncIntegration(fileId);
    SpreadsheetApp.getUi().alert('Sync completed successfully!');
  } catch (err) {
    Logger.log('Error in syncNow: ' + (err && err.stack ? err.stack : err));
    SpreadsheetApp.getUi().alert('Sync failed: ' + err.message);
  }
}

function dryRun() {
  try {
    ensureConfig(/*allowEmpty=*/true); // allow missing config; no network calls
    const file = buildCustomIntegrationFile();
    const usersCount = file.users.length;
    const licenseCount = file.licenses.length;
    const licensedUsers = file.licenses.reduce((acc, l) => acc + (l.users ? l.users.length : 0), 0);
    Logger.log(JSON.stringify(file, null, 2));
    SpreadsheetApp.getUi().alert(
      'Dry Run complete\n' +
      `Users: ${usersCount}\n` +
      `Licenses: ${licenseCount}\n` +
      `Total Licensed Users: ${licensedUsers}\n\n` +
      'See Logs for details (View → Logs).'
    );
  } catch (err) {
    Logger.log('Error in dryRun: ' + (err && err.stack ? err.stack : err));
    SpreadsheetApp.getUi().alert('Dry Run failed: ' + err.message);
  }
}

function validateSheet() {
  try {
    const sheet = SpreadsheetApp.getActiveSheet();
    const { headerMap } = getHeaderMap(sheet);

    const required = ['name', 'email', 'role', 'seat_type'];
    const missing = required.filter(k => headerMap[k] == null);

    const anomalies = analyzeAnomalies(sheet, headerMap);

    let msg = 'Validation results:\n';
    msg += missing.length ? `❌ Missing required headers: ${missing.join(', ')}\n` : '✅ All required headers present.\n';

    if (anomalies.missingEmails > 0)
      msg += `⚠️ Rows missing email: ${anomalies.missingEmails}\n`;
    if (anomalies.unknownSeatTypes.length)
      msg += `⚠️ Unknown seat types: ${Array.from(new Set(anomalies.unknownSeatTypes)).join(', ')}\n`;
    if (anomalies.unknownRoles.length)
      msg += `ℹ️ Unrecognized roles (included as-is): ${Array.from(new Set(anomalies.unknownRoles)).join(', ')}\n`;

    SpreadsheetApp.getUi().alert(msg);
  } catch (err) {
    Logger.log('Error in validateSheet: ' + (err && err.stack ? err.stack : err));
    SpreadsheetApp.getUi().alert('Validate failed: ' + err.message);
  }
}

/* -------------------------
   Core build
------------------------- */

function buildCustomIntegrationFile() {
  const sheet = SpreadsheetApp.getActiveSheet();
  const data = sheet.getDataRange().getValues();
  if (!data || data.length <= 1) {
    throw new Error('No data found in spreadsheet (need a header row + at least one data row).');
  }

  const { headerMap } = getHeaderMap(sheet);

  // Required headers
  const required = ['name', 'email', 'role', 'seat_type'];
  const missing = required.filter(k => headerMap[k] == null);
  if (missing.length) {
    throw new Error('Missing required headers: ' + missing.join(', '));
  }

  // Remove header row
  data.shift();

  const usersOut = [];
  const userSeen = new Set();

  const licensesMap = {}; // licenseName -> [{email, licenseStatus, lastUsedDate?}]

  // --- seat-type specific status rule
  const computeLicenseStatus = (licenseName, lastUsedDate) => {
    if (licenseName === 'No Seat Type Assigned' && !lastUsedDate) {
      return 'inactive';
    }
    return 'active';
  };

  const pushLicenseUser = (licenseName, email, lastUsedDate) => {
    if (!licensesMap[licenseName]) licensesMap[licenseName] = [];
    const licenseStatus = computeLicenseStatus(licenseName, lastUsedDate);
    const entry = { email: email, licenseStatus: licenseStatus };
    if (lastUsedDate) entry.lastUsedDate = lastUsedDate; // only include when present
    licensesMap[licenseName].push(entry);
  };

  // Analyze per-row
  for (let i = 0; i < data.length; i++) {
    const row = data[i];

    const get = (key) => {
      const idx = headerMap[key];
      return idx != null ? (row[idx] !== '' ? row[idx] : null) : null;
    };

    const fullNameRaw = (get('name') || '').toString().trim();
    const emailRaw = (get('email') || '').toString().trim();
    const roleRaw = (get('role') || '').toString().trim();
    const seatTypeRaw = (get('seat_type') || '').toString().trim();

    if (!emailRaw) {
      Logger.log(`Row ${i + 2}: missing email — skipping`);
      continue;
    }
    if (!fullNameRaw) {
      Logger.log(`Row ${i + 2}: missing name (email=${emailRaw}) — using email local part as firstName`);
    }

    // Normalize role & seat type (role value is informational for now)
    const normalizedRole = normalizeRole(roleRaw);
    const { normalized: normalizedSeat, warning: seatWarn } = normalizeSeatType(seatTypeRaw);

    if (seatWarn) Logger.log(`Row ${i + 2} (${emailRaw}): ${seatWarn}`);
    if (normalizedRole.warning) Logger.log(`Row ${i + 2} (${emailRaw}): ${normalizedRole.warning}`);

    // Name split: last token -> lastName; rest -> firstName
    let firstName = '';
    let lastName = '';
    if (fullNameRaw) {
      const parts = fullNameRaw.split(/\s+/);
      if (parts.length === 1) {
        firstName = parts[0];
        lastName = '';
      } else {
        lastName = parts.pop();
        firstName = parts.join(' ');
      }
    } else {
      firstName = (emailRaw.split('@')[0] || '').replace(/[._-]+/g, ' ');
      lastName = '';
    }

    // Dates (compare as Date objects, format only for output)
    const lastSeenFigma  = toDateOrNull(get('last_seen_figma'));
    const lastSeenDev    = toDateOrNull(get('last_seen_dev_mode'));
    const lastSeenJam    = toDateOrNull(get('last_seen_figjam'));
    const lastSeenSlides = toDateOrNull(get('last_seen_slides'));

    const lastEditFigma  = toDateOrNull(get('last_edit_figma'));
    const lastEditJam    = toDateOrNull(get('last_edit_figjam'));
    const lastEditSlides = toDateOrNull(get('last_edit_slides'));

    const figmaUsage     = mostRecentDate(lastSeenFigma, lastEditFigma);
    const figjamUsage    = mostRecentDate(lastSeenJam, lastEditJam);
    const slidesUsage    = mostRecentDate(lastSeenSlides, lastEditSlides);
    const devModeUsage   = mostRecentDate(lastSeenDev);

    const lastUsed       = mostRecentDate(figmaUsage, figjamUsage, slidesUsage, devModeUsage);
    const lastUsedOut    = lastUsed ? formatDateUTC(lastUsed) : null;

    // Users array (dedupe by email)
    if (!userSeen.has(emailRaw.toLowerCase())) {
      userSeen.add(emailRaw.toLowerCase());
      usersOut.push({
        email: emailRaw,
        firstName: firstName,
        lastName: lastName,
        status: 'active'
      });
    }

    // License assignment
    const licenseName = normalizedSeat || 'No Seat Type Assigned';
    pushLicenseUser(licenseName, emailRaw, lastUsedOut);
  }

  // Build licenses array
  const licensesOut = Object.keys(licensesMap).map(name => ({
    name,
    users: licensesMap[name]
  }));

  const file = {
    users: usersOut,
    licenses: licensesOut
  };

  Logger.log(`Build complete: users=${usersOut.length}, licenses=${licensesOut.length}`);
  return file;
}

/* -------------------------
   Upload & Sync (Torii API)
------------------------- */

function syncIntegration(fileId) {
  const resp = UrlFetchApp.fetch('https://api.toriihq.com/v1.0/services/sync/custom', {
    muteHttpExceptions: false,
    method: 'put',
    contentType: 'application/json',
    payload: JSON.stringify({
      idFile: fileId,
      idAppAccount: APP_ID
    }),
    headers: { Authorization: `Bearer ${API_KEY}` }
  });
  Logger.log(resp.getResponseCode() + ' ' + resp.getContentText());
}

function uploadFile(file) {
  // 1) Get signed URL
  let response = UrlFetchApp.fetch('https://api.toriihq.com/v1.0/files/url?name=custom_integration.json&type=customIntegrationData', {
    headers: { Authorization: `Bearer ${API_KEY}` }
  });
  let json = JSON.parse(response.getContentText());
  const s3url = json.uploadFileParameters.url;
  const filePath = json.uploadFileParameters.filePath;

  // 2) PUT to S3 (use a real MIME type)
  response = UrlFetchApp.fetch(s3url, {
    method: 'put',
    contentType: 'application/json',
    payload: JSON.stringify(file)
  });

  // 3) Register file
  response = UrlFetchApp.fetch('https://api.toriihq.com/v1.0/files', {
    method: 'post',
    contentType: 'application/json',
    headers: { Authorization: `Bearer ${API_KEY}` },
    payload: JSON.stringify({
      path: filePath,
      type: 'customIntegrationData'
    })
  });

  json = JSON.parse(response.getContentText());
  const fileId = json.id;
  Logger.log('Uploaded fileId: ' + fileId);
  return fileId;
}

/* -------------------------
   Helpers – headers & dates
------------------------- */

// Build a normalized header map: logicalKey -> column index
function getHeaderMap(sheet) {
  const values = sheet.getDataRange().getValues();
  if (!values || values.length === 0) throw new Error('Sheet appears empty.');
  const rawHeaders = values[0].map(h => (h == null ? '' : String(h)));

  // Normalize a header (lowercase, trim, collapse spaces, convert to underscores)
  const norm = (s) => s.toLowerCase().trim().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '_');

  const normalized = rawHeaders.map(h => norm(h));
  const idxByHeader = {};
  normalized.forEach((h, i) => { idxByHeader[h] = i; });

  // Map logical keys to likely header variants
  const findCol = (aliases) => {
    for (const a of aliases) {
      if (idxByHeader[a] != null) return idxByHeader[a];
    }
    return null;
  };

  const headerMap = {
    // required
    name: findCol(['name', 'full_name']),
    email: findCol(['email', 'e_mail']),
    role: findCol(['role', 'user_role']),
    seat_type: findCol(['seat_type', 'seat', 'license', 'seattype']),
    // optional (used when present)
    paid_seat_date_assigned: findCol(['paid_seat_date_assigned']),
    job_title: findCol(['job_title', 'title']),
    date_added: findCol(['date_added']),
    figma_date_upgraded: findCol(['figma_date_upgraded']),
    figjam_date_upgraded: findCol(['figjam_date_upgraded']),
    dev_mode_date_upgraded: findCol(['dev_mode_date_upgraded']),
    last_seen_figma: findCol(['last_seen_figma']),
    last_seen_dev_mode: findCol(['last_seen_dev_mode', 'last_seen_dev']),
    last_seen_figjam: findCol(['last_seen_figjam']),
    last_seen_slides: findCol(['last_seen_slides']),
    last_edit_figma: findCol(['last_edit_figma']),
    last_edit_figjam: findCol(['last_edit_figjam']),
    last_edit_slides: findCol(['last_edit_slides']),
    scim_billing_model_schema: findCol(['scim_billing_model_schema'])
  };

  return { headerMap };
}

// Convert any cell value to Date or null (logs invalids)
function toDateOrNull(value) {
  if (value == null || value === '') return null;

  // Accept Date objects (from Sheets) directly
  if (Object.prototype.toString.call(value) === '[object Date]') {
    return isNaN(value.getTime()) ? null : value;
  }

  // Try parsing strings/numbers
  const s = String(value).trim();
  if (!s) return null;

  const d = new Date(s);
  if (isNaN(d.getTime())) {
    // Try DD/MM/YYYY or MM/DD/YYYY variants as a fallback
    const m = s.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2,4})$/);
    if (m) {
      const dd = parseInt(m[1], 10);
      const mm = parseInt(m[2], 10) - 1;
      const yyyy = parseInt(m[3].length === 2 ? '20' + m[3] : m[3], 10);
      const alt = new Date(Date.UTC(yyyy, mm, dd));
      return isNaN(alt.getTime()) ? null : alt;
    }
    Logger.log('Invalid date: ' + s);
    return null;
  }
  return d;
}

// Return most recent (max) of Date args; null if none
function mostRecentDate() {
  const dates = Array.prototype.slice.call(arguments).filter(Boolean);
  if (!dates.length) return null;
  let maxTs = -Infinity;
  let maxDate = null;
  for (const d of dates) {
    const ts = d.getTime();
    if (!isNaN(ts) && ts > maxTs) {
      maxTs = ts;
      maxDate = d;
    }
  }
  return maxDate;
}

// Format to YYYY-MM-dd UTC
function formatDateUTC(date) {
  if (!date) return null;
  return Utilities.formatDate(date, 'UTC', 'yyyy-MM-dd');
}

/* -------------------------
   Helpers – normalization & analysis
------------------------- */

function normalizeSeatType(seatRaw) {
  if (!seatRaw) return { normalized: null, warning: null };
  const v = seatRaw.toString().trim().toLowerCase();

  // Known synonyms → canonical
  const synonyms = {
    'viewer': 'view',
    'read': 'view',
    'dev mode': 'dev',
    'developer': 'dev',
    'collaborator': 'collab',
    'full seat': 'full'
  };

  const allowed = new Set(['view', 'dev', 'collab', 'full', 'empty']);

  const mapped = synonyms[v] || v;

  if (mapped === '' || mapped == null) return { normalized: null, warning: null };

  if (allowed.has(mapped)) return { normalized: mapped, warning: null };

  // Unknown value: pass-through but warn
  return {
    normalized: mapped, // used as license name so you can spot upstream data issues
    warning: `Unknown seat_type "${seatRaw}" → using "${mapped}" as license name`
  };
}

function normalizeRole(roleRaw) {
  if (!roleRaw) return { value: null, warning: null };
  const v = roleRaw.toString().trim().toLowerCase();
  const allowed = new Set(['member', 'admin', 'guest', 'provisional']);
  if (allowed.has(v)) return { value: v, warning: null };
  return { value: v, warning: `Unrecognized role "${roleRaw}" (included as-is)` };
}

function analyzeAnomalies(sheet, headerMap) {
  const data = sheet.getDataRange().getValues();
  if (!data || data.length <= 1) return { missingEmails: 0, unknownSeatTypes: [], unknownRoles: [] };

  const rows = data.slice(1);
  let missingEmails = 0;
  const unknownSeatTypes = [];
  const unknownRoles = [];

  const seatIdx = headerMap.seat_type;
  const roleIdx = headerMap.role;
  const emailIdx = headerMap.email;

  const allowedSeat = new Set(['view', 'dev', 'collab', 'full', 'empty']);
  const roleAllowed = new Set(['member', 'admin', 'guest', 'provisional']);

  rows.forEach((row, i) => {
    const email = emailIdx != null ? String(row[emailIdx] || '').trim() : '';
    if (!email) missingEmails++;

    if (seatIdx != null) {
      const raw = String(row[seatIdx] || '').trim();
      if (raw) {
        const { normalized } = normalizeSeatType(raw);
        if (normalized && !allowedSeat.has(normalized) &&
            normalized !== 'No Seat Type Assigned') {
          unknownSeatTypes.push(raw);
        }
      }
    }
    if (roleIdx != null) {
      const rawR = String(row[roleIdx] || '').trim();
      if (rawR) {
        const r = rawR.toLowerCase();
        if (!roleAllowed.has(r)) unknownRoles.push(rawR);
      }
    }
  });

  return { missingEmails, unknownSeatTypes, unknownRoles };
}

/* -------------------------
   Config helpers
------------------------- */

function ensureConfig(allowEmpty) {
  let docProperties = PropertiesService.getDocumentProperties();
  API_KEY = docProperties.getProperty('api_key');
  APP_ID  = docProperties.getProperty('app_id');

  if (!API_KEY || !APP_ID) {
    if (allowEmpty) return; // for Dry Run without network
    readConfig(); // will prompt
    docProperties = PropertiesService.getDocumentProperties();
    API_KEY = docProperties.getProperty('api_key');
    APP_ID  = docProperties.getProperty('app_id');

    if (!API_KEY || !APP_ID) {
      throw new Error('Missing API Key or App ID; please run "Reconfigure" from the Torii menu.');
    }
  }
}

Step 6 — Run it (Validate → Dry Run → Sync)

  1. Reconfigure from the Torii menu to store your API Key and App ID.
  2. Validate Sheet to confirm headers and spot anomalies (unknown seat types, missing emails).
  3. Dry Run to build JSON and view counts in logs (no network calls).
  4. Sync now to upload the JSON and trigger the Custom Integration sync.

Behind the scenes — data flow

Google Sheet
    |
    | Apps Script builds JSON
    v
Torii Files (Signed URL)
    |
    | PUT JSON → S3
    v
Torii Files (POST /files)  --> returns fileId
    |
    | PUT /services/sync/custom with fileId + appId
    v
Custom Integration runs → Users & Licenses appear in Torii

Troubleshooting

  • “Missing required headers” Use Validate Sheet. Add any missing fields: name, email, role, seat_type.
  • Users missing emails Rows without email are skipped (cannot create a valid user).
  • Unknown seat types Logged and used verbatim as license names. Fix upstream when convenient.
  • No Seat Type Assigned shows Active The script marks users in the fallback license inactive when they have no usage; otherwise they appear active.
  • Dates & timezone Dates are compared as Date objects; final lastUsedDate is formatted YYYY-MM-dd in UTC.

Next steps

  • Extend the “inactive-if-no-usage” rule to additional seat types (e.g., empty) if that matches your policy.
  • Add per-business-unit synonyms to normalizeSeatType() if you see varied terminology across exports.
  • Use this article as a foundation for other design tools with similar exports—swap seat logic and usage fields as needed.
👍

Congratulations! You've successfully synced a custom integration. You can use this approach to sync data into Torii for any apps that don't have APIs but do have some form of downloadable extract of users, licenses, and usage data.