269 lines
26 KiB
JavaScript
269 lines
26 KiB
JavaScript
/*
|
||
Workday integration service for managing the connection configuration and synchronizing worker data from Workday into the local system. This module provides functions to get and save the Workday connection settings, fetch an access token using OAuth client credentials, normalize worker data from the Workday API, and perform synchronization of workers on demand or on a scheduled basis. The synchronization process involves fetching worker data from Workday, normalizing it into a consistent format, and then upserting employee and contact records in the local database based on the Workday worker information. The module also includes error handling and auditing for tracking changes to the connection settings and synchronization actions.
|
||
*/
|
||
|
||
const moment = require('moment');
|
||
const { audit, json, now, one, run } = require('../db');
|
||
const { upsertEmployee } = require('./assignments');
|
||
const { upsertWorkdayContact } = require('./contacts');
|
||
console.log(`${moment().format()} 🔧 Workday service module loaded`);
|
||
// Convert a database row into a public-facing connection object, excluding sensitive information like the client secret and including a flag to indicate whether the connection is fully configured based on the presence of required fields; this allows us to safely expose connection information in API responses without risking credential leaks.
|
||
function publicConnection(row) {
|
||
console.log(`${moment().format()} 🔍 Mapping database row to public connection object for row ID: ${row?.id}`);
|
||
if (!row) {
|
||
// Note: if no connection row is found in the database, we return null to indicate that there is no configured connection; this allows API endpoints to handle the absence of a connection gracefully.
|
||
console.log(`${moment().format()} ⚠️ No Workday connection found in database`);
|
||
return null;
|
||
}
|
||
return {
|
||
id: row.id,
|
||
name: row.name,
|
||
tenant: row.tenant,
|
||
base_url: row.base_url,
|
||
token_url: row.token_url,
|
||
client_id: row.client_id,
|
||
workers_path: row.workers_path,
|
||
enabled: Boolean(row.enabled),
|
||
sync_enabled: Boolean(row.sync_enabled),
|
||
sync_interval_hours: row.sync_interval_hours || 24,
|
||
last_sync_at: row.last_sync_at,
|
||
last_sync_status: row.last_sync_status,
|
||
configured: Boolean(row.base_url && row.token_url && row.client_id && row.client_secret)
|
||
};
|
||
}
|
||
|
||
// Clear any cached connection data. In this implementation, we don't have an actual cache layer, but this function can be expanded in the future to clear in-memory caches or other caching mechanisms if needed; for now, it serves as a placeholder to indicate where cache clearing logic would go.
|
||
function clearCache() {
|
||
console.log(`${moment().format()} 🧹 Clearing Workday connection cache (no-op)`);
|
||
}
|
||
|
||
// Split a full name into first and last name components. This is a simple heuristic that takes the first word as the first name and the rest as the last name, which may not be perfect but provides a reasonable default for many cases; this allows us to extract more granular name information from a single full name field when normalizing worker data from Workday.
|
||
function splitName(full) {
|
||
console.log(`${moment().format()} 🔍 Splitting full name into first and last name: "${full}"`);
|
||
const parts = String(full || '').trim().split(/\s+/).filter(Boolean);
|
||
if (!parts.length) return { first: null, last: null }; // Note: if the full name is empty or only contains whitespace, we return null for both first and last name to indicate that we couldn't extract any name information; this allows us to handle cases where the name field might be missing or invalid without causing errors.
|
||
if (parts.length === 1) return { first: parts[0], last: null }; // Note: if the full name only contains one word, we treat it as the first name and set the last name to null; this allows us to handle cases where only a single name is provided without assuming that it must be a last name.
|
||
console.log(`${moment().format()} ✅ Split name into first: "${parts[0]}", last: "${parts.slice(1).join(' ')}"`);
|
||
return { first: parts[0], last: parts.slice(1).join(' ') };
|
||
}
|
||
|
||
// Get the current Workday connection configuration as a public-facing object. This function retrieves the connection information from the database and maps it to a format that can be safely exposed in API responses, excluding sensitive fields and including a flag for whether the connection is fully configured; this allows API endpoints to provide connection information to clients without risking credential exposure.
|
||
function getConnection() {
|
||
console.log(`${moment().format()} 🔍 Retrieving Workday connection from database`);
|
||
return publicConnection(one('SELECT * FROM workday_connections ORDER BY id LIMIT 1'));
|
||
}
|
||
// Get the raw Workday connection row from the database, including sensitive fields. This function is used internally when we need to access the full connection information, such as when saving updates or performing synchronization, and should not be exposed in API responses; this allows us to maintain a clear separation between internal data access and public-facing data structures.
|
||
function getPrivateConnection() {
|
||
console.log(`${moment().format()} 🔍 Retrieving private Workday connection from database`);
|
||
return one('SELECT * FROM workday_connections ORDER BY id LIMIT 1');
|
||
}
|
||
|
||
// Save the Workday connection configuration to the database, either by creating a new record or updating the existing one. This function takes the input from the API request body, merges it with any existing connection information, and saves it to the database while ensuring that required fields are present and that sensitive information like the client secret is handled appropriately; this allows API endpoints to update the connection settings while maintaining data integrity and security.
|
||
function saveConnection(body, userId) {
|
||
console.log(`${moment().format()} 🔄 Saving Workday connection with input: ${JSON.stringify(body)}`);
|
||
const timestamp = now();
|
||
const existing = getPrivateConnection();
|
||
const payload = {
|
||
name: body.name || existing?.name || 'Primary Workday',
|
||
tenant: body.tenant ?? existing?.tenant ?? '',
|
||
base_url: body.base_url ?? existing?.base_url ?? '',
|
||
token_url: body.token_url ?? existing?.token_url ?? '',
|
||
client_id: body.client_id ?? existing?.client_id ?? '',
|
||
client_secret: body.client_secret ? body.client_secret : existing?.client_secret ?? '',
|
||
workers_path: body.workers_path ?? existing?.workers_path ?? '/workers',
|
||
enabled: body.enabled === undefined ? Number(existing?.enabled || 0) : (body.enabled ? 1 : 0),
|
||
sync_enabled: body.sync_enabled === undefined ? Number(existing?.sync_enabled || 0) : (body.sync_enabled ? 1 : 0),
|
||
sync_interval_hours: body.sync_interval_hours === undefined ? (existing?.sync_interval_hours || 24) : Math.max(1, Number(body.sync_interval_hours) || 24)
|
||
};
|
||
|
||
if (existing) {
|
||
// Note: when updating an existing connection, we only update the fields that are provided in the input, while keeping the existing values for any fields that are not included; this allows for partial updates without requiring the client to resend all connection information, and it also ensures that we don't accidentally overwrite existing values with empty or default values if they are not included in the update request.
|
||
console.log(`${moment().format()} 🔄 Updating existing Workday connection with ID: ${existing.id}`);
|
||
run(
|
||
`UPDATE workday_connections SET
|
||
name = ?, tenant = ?, base_url = ?, token_url = ?, client_id = ?, client_secret = ?,
|
||
workers_path = ?, enabled = ?, sync_enabled = ?, sync_interval_hours = ?, updated_at = ?
|
||
WHERE id = ?`,
|
||
[payload.name, payload.tenant, payload.base_url, payload.token_url, payload.client_id, payload.client_secret, payload.workers_path, payload.enabled, payload.sync_enabled, payload.sync_interval_hours, timestamp, existing.id]
|
||
);
|
||
} else {
|
||
// Note: when creating a new connection, we require all necessary fields to be provided in the input, and we insert a new record into the database with the provided values; this allows us to ensure that we have a complete set of connection information when creating a new connection, and it also allows us to handle the case where there is no existing connection in the database.
|
||
console.log(`${moment().format()} ➕ Creating new Workday connection`);
|
||
run(
|
||
`INSERT INTO workday_connections (
|
||
name, tenant, base_url, token_url, client_id, client_secret, workers_path, enabled, sync_enabled, sync_interval_hours, created_at, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
[payload.name, payload.tenant, payload.base_url, payload.token_url, payload.client_id, payload.client_secret, payload.workers_path, payload.enabled, payload.sync_enabled, payload.sync_interval_hours, timestamp, timestamp]
|
||
);
|
||
}
|
||
|
||
const saved = getConnection();
|
||
audit(userId, 'update', 'workday_connection', saved.id, null, { ...saved, client_secret: '[redacted]' });
|
||
console.log(`${moment().format()} 📤 Auditing update to Workday connection: ${saved.id}`);
|
||
return saved;
|
||
}
|
||
|
||
// Fetch an access token from Workday using the client credentials flow. This function constructs the appropriate authorization header using the client ID and client secret, makes a POST request to the token URL, and returns the access token if the request is successful; this allows us to authenticate with the Workday API and obtain a token that can be used for subsequent API requests to fetch worker data.
|
||
async function fetchAccessToken(connection) {
|
||
console.log(`${moment().format()} 🔐 Fetching Workday access token from token URL: ${connection.token_url}`);
|
||
const basic = Buffer.from(`${connection.client_id}:${connection.client_secret}`).toString('base64');
|
||
const response = await fetch(connection.token_url, {
|
||
method: 'POST',
|
||
headers: {
|
||
Authorization: `Basic ${basic}`,
|
||
'Content-Type': 'application/x-www-form-urlencoded'
|
||
},
|
||
body: new URLSearchParams({ grant_type: 'client_credentials' })
|
||
});
|
||
if (!response.ok) {
|
||
// Note: if the token request fails, we throw an error with the status code to indicate that we were unable to authenticate with Workday; this allows API endpoints that call this function to handle authentication errors appropriately and provide feedback to the user.
|
||
console.log(`${moment().format()} ❌ Workday token request failed with status: ${response.status}`);
|
||
throw new Error(`Workday token request failed: ${response.status}`);
|
||
}
|
||
const body = await response.json();
|
||
if (!body.access_token) {
|
||
// Note: if the token response does not include an access token, we throw an error to indicate that the authentication was unsuccessful; this allows us to catch cases where the token request might succeed but still fail to provide the necessary token for API requests, and it also provides feedback for troubleshooting authentication issues.
|
||
console.log(`${moment().format()} ❌ Workday token response did not include an access token`);
|
||
throw new Error('Workday token response did not include an access token');
|
||
}
|
||
console.log(`${moment().format()} ✅ Successfully fetched Workday access token`);
|
||
return body.access_token;
|
||
}
|
||
|
||
// Normalize worker data from the Workday API into a consistent format for upserting into the local database. This function takes the raw worker data from Workday, which may have varying field names and structures, and maps it to a standardized format with consistent field names for things like worker ID, name, email, department, manager information, and so on; this allows us to handle different versions of the Workday API or different configurations that might return worker data in slightly different formats while still being able to process it in a consistent way for synchronization.
|
||
function normalizeWorkers(payload) {
|
||
// Note: the payload from Workday can come in various formats depending on the API version and configuration, so we check for multiple possible structures to extract the array of worker data; this allows us to be flexible in handling different responses from Workday while still being able to normalize the worker data effectively.
|
||
const rows = Array.isArray(payload)
|
||
? payload
|
||
: payload.workers || payload.Workers || payload.data || payload.value || [];
|
||
|
||
// Note: when normalizing each worker, we check for multiple possible field names for each piece of information (e.g., name, email, department) to account for variations in the Workday API response; we also use the splitName function to extract first and last names from a full name field when necessary, and we include the original worker data in the source_payload for reference; this allows us to create a consistent format for worker data that can be used for upserting into the local database while still retaining the original information from Workday for troubleshooting and auditing purposes.
|
||
console.log(`${moment().format()} 🔍 Normalizing Workday worker data with ${rows.length} workers found in payload`);
|
||
return rows.map((worker) => {
|
||
const name = worker.name || worker.fullName || worker.Full_Name || worker.descriptor || [worker.firstName, worker.lastName].filter(Boolean).join(' ');
|
||
const fallbackName = splitName(name);
|
||
const managerName = worker.managerName || worker.manager?.descriptor;
|
||
const managerSplit = splitName(managerName);
|
||
console.log(`${moment().format()} 🔍 Normalizing worker data for: ${name}`);
|
||
return {
|
||
workday_worker_id: worker.id || worker.workerId || worker.worker_id || worker.Worker_ID || worker.workerReference?.id,
|
||
employee_number: worker.employeeNumber || worker.employee_number || worker.Worker_ID || worker.workerNumber,
|
||
name,
|
||
first_name: worker.firstName || worker.legalFirstName || worker.First_Name || fallbackName.first,
|
||
last_name: worker.lastName || worker.legalLastName || worker.Last_Name || fallbackName.last,
|
||
email: worker.email || worker.workEmail || worker.primaryWorkEmail || worker.Email,
|
||
department: worker.department || worker.organization || worker.Department || worker.supervisoryOrganization?.descriptor,
|
||
location: worker.location || worker.workLocation || worker.Location || worker.location?.descriptor,
|
||
manager_name: managerName,
|
||
manager_first_name: worker.manager?.firstName || managerSplit.first,
|
||
manager_last_name: worker.manager?.lastName || managerSplit.last,
|
||
manager_email: worker.managerEmail || worker.manager?.email,
|
||
start_date: worker.hireDate || worker.startDate || worker.Hire_Date || worker.continuousServiceDate,
|
||
status: worker.status || (worker.active === false ? 'inactive' : 'active'),
|
||
source: 'workday',
|
||
source_payload: worker,
|
||
last_synced_at: now()
|
||
};
|
||
});
|
||
}
|
||
|
||
// Synchronize workers from Workday by fetching the worker data using the API, normalizing it, and then upserting employee and contact records in the local database based on the Workday worker information. This function handles the entire synchronization process, including error handling and auditing, and it updates the last synchronization timestamp and status in the database after the process is complete; this allows us to keep our local employee and contact records in sync with Workday on demand or on a scheduled basis, ensuring that we have up-to-date information about our workforce for reporting and analysis.
|
||
async function syncWorkers(userId) {
|
||
console.log(`${moment().format()} 🔄 Starting Workday worker synchronization`);
|
||
const connection = getPrivateConnection();
|
||
if (!connection || !connection.enabled) {
|
||
// Note: if there is no connection configured or if the connection is not enabled, we throw an error to indicate that synchronization cannot proceed; this allows API endpoints that call this function to provide feedback to the user about the need to configure and enable the Workday connection before attempting to synchronize workers.
|
||
console.log(`${moment().format()} ⚠️ Workday connection is not enabled or not configured`);
|
||
throw Object.assign(new Error('Workday connection is not enabled'), { status: 400 });
|
||
}
|
||
if (!connection.base_url || !connection.token_url || !connection.client_id || !connection.client_secret) {
|
||
// Note: if the connection is missing any of the required fields for making API requests (base URL, token URL, client ID, or client secret), we throw an error to indicate that the connection is not properly configured; this allows us to catch configuration issues before attempting to make API requests and provides feedback for troubleshooting the connection settings.
|
||
console.log(`${moment().format()} ⚠️ Workday connection is missing URL or OAuth credentials`);
|
||
throw Object.assign(new Error('Workday connection is missing URL or OAuth credentials'), { status: 400 });
|
||
}
|
||
|
||
const token = await fetchAccessToken(connection); // Note: we fetch an access token using the client credentials flow before making API requests to Workday; this allows us to authenticate with the Workday API and obtain a token that can be used for subsequent requests to fetch worker data.
|
||
const url = new URL(connection.workers_path || '/workers', connection.base_url); // Note: we construct the URL for fetching workers by combining the base URL from the connection with the workers path, which allows for flexibility in how the API endpoint is structured; this also allows us to support different versions of the Workday API or custom configurations that might use different paths for accessing worker data.
|
||
console.log(`${moment().format()} 🔍 Fetching workers from Workday API at URL: ${url.href}`);
|
||
const response = await fetch(url, {
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
Accept: 'application/json'
|
||
}
|
||
});
|
||
console.log(`${moment().format()} 📥 Received response from Workday API`);
|
||
if (!response.ok) {
|
||
// Note: if the API request to fetch workers fails, we throw an error with the status code to indicate that we were unable to retrieve worker data from Workday; this allows API endpoints that call this function to handle API errors appropriately and provide feedback to the user about issues with fetching data from Workday.
|
||
console.log(`${moment().format()} ❌ Workday workers request failed with status: ${response.status}`);
|
||
throw new Error(`Workday workers request failed: ${response.status}`);
|
||
}
|
||
|
||
// Note: after successfully fetching the worker data from Workday, we normalize it into a consistent format and then upsert employee and contact records in the local database for each worker; this allows us to keep our local records in sync with Workday and ensures that we have up-to-date information about our workforce for reporting and analysis.
|
||
const workers = normalizeWorkers(await response.json());
|
||
for (const worker of workers) {
|
||
// Note: when processing each worker from Workday, we log the name and Workday ID for tracking purposes, and then we upsert employee and contact records in the local database based on the worker information; this allows us to maintain accurate employee and contact records that reflect the current state of our workforce as represented in Workday, and it also provides visibility into the synchronization process for troubleshooting and auditing.
|
||
console.log(`${moment().format()} 🔄 Upserting worker into local database: ${worker.name} (Workday ID: ${worker.workday_worker_id})`);
|
||
upsertEmployee(worker); // Note: we upsert employee records based on the worker information, which allows us to keep our local employee data in sync with the worker data from Workday, and it also ensures that we have accurate employee records for reporting and analysis.
|
||
upsertWorkdayContact(worker); // Note: we also upsert contact records based on the worker information, which allows us to maintain accurate contact information for our workforce and ensures that we can reach out to employees as needed based on the contact details provided in Workday.
|
||
}
|
||
|
||
// Note: after the synchronization process is complete, we update the last synchronization timestamp and status in the database to reflect the results of the sync, and we audit the synchronization action for tracking purposes; this allows us to maintain a record of when synchronizations occurred and how many workers were imported, which can be useful for troubleshooting and reporting on the integration with Workday.
|
||
const timestamp = now();
|
||
run('UPDATE workday_connections SET last_sync_at = ?, last_sync_status = ?, updated_at = ? WHERE id = ?', [
|
||
timestamp,
|
||
`Imported ${workers.length} workers`,
|
||
timestamp,
|
||
connection.id
|
||
]);
|
||
audit(userId, 'sync', 'workday_workers', connection.id, null, { imported: workers.length });
|
||
console.log(`${moment().format()} ✅ Workday worker synchronization complete: ${workers.length} workers imported`);
|
||
return { imported: workers.length, workers };
|
||
}
|
||
|
||
// Import workers from a provided payload, which is expected to be in the same format as the normalized worker data from Workday. This function allows for manual import of worker data, such as from a file upload or an API request with worker information, and it processes the data in the same way as the synchronization function by upserting employee and contact records in the local database; this provides flexibility for importing worker data from sources other than direct API calls to Workday while still maintaining consistency in how the data is processed and stored.
|
||
function importWorkersFromPayload(workers, userId) {
|
||
console.log(`${moment().format()} 🔄 Importing workers from provided payload with ${workers.length} workers`);
|
||
const normalized = normalizeWorkers(workers); // Note: we normalize the provided worker data using the same normalization function as the synchronization process to ensure that the data is in a consistent format for upserting into the local database; this allows us to handle different input formats while still processing the worker data in a consistent way.
|
||
for (const worker of normalized) {
|
||
// Note: when importing workers from a payload, we process the data in the same way as the synchronization function by normalizing it and then upserting employee and contact records in the local database; this allows us to maintain consistency in how worker data is handled regardless of the source, and it also provides a way to import worker data from sources other than direct API calls to Workday.
|
||
console.log(`${moment().format()} 🔄 Upserting worker from payload into local database: ${worker.name} (Workday ID: ${worker.workday_worker_id})`);
|
||
upsertEmployee(worker); // Note: we upsert employee records based on the worker information, which allows us to keep our local employee data in sync with the worker data from Workday, and it also ensures that we have accurate employee records for reporting and analysis.
|
||
upsertWorkdayContact(worker); // Note: we also upsert contact records based on the worker information, which allows us to maintain accurate contact information for our workforce and ensures that we can reach out to employees as needed based on the contact details provided in Workday.
|
||
}
|
||
audit(userId, 'import', 'workday_workers', null, null, { imported: normalized.length, source: 'payload' });
|
||
console.log(`${moment().format()} ✅ Worker import from payload complete: ${normalized.length} workers imported`);
|
||
return { imported: normalized.length };
|
||
}
|
||
|
||
// Run an automatic sync if it's enabled, configured, and the interval has elapsed.
|
||
async function runScheduledSync() {
|
||
console.log(`${moment().format()} ⏰ Checking if scheduled Workday sync should run`);
|
||
const connection = getPrivateConnection();
|
||
if (!connection || !connection.sync_enabled || !connection.enabled) {
|
||
// Note: if the connection is not enabled, we skip the sync to avoid unnecessary API calls and processing; this allows us to control when automatic synchronizations run and prevents errors when attempting to connect to Workday.
|
||
console.log(`${moment().format()} ⏰ Scheduled Workday sync skipped: Connection not enabled`);
|
||
return { skipped: true };
|
||
}
|
||
if (!connection.base_url || !connection.token_url || !connection.client_id || !connection.client_secret) {
|
||
// Note: before running a scheduled sync, we check if the connection is fully configured with all required fields; if any of the necessary configuration is missing, we skip the sync to avoid errors when attempting to connect to Workday, and we log a message indicating that the sync was skipped due to incomplete configuration; this allows us to ensure that automatic synchronizations only run when the connection is properly set up and prevents unnecessary errors or failed sync attempts.
|
||
console.log(`${moment().format()} ⏰ Scheduled Workday sync skipped: Incomplete connection configuration`);
|
||
return { skipped: true };
|
||
}
|
||
const intervalMs = (Number(connection.sync_interval_hours) || 24) * 3600000;
|
||
if (connection.last_sync_at && Date.now() - new Date(connection.last_sync_at).getTime() < intervalMs) {
|
||
// Note: if the last synchronization occurred within the configured interval, we skip the sync to avoid unnecessary API calls and processing; this allows us to control the frequency of automatic synchronizations and prevent excessive load on the Workday API or our local system while still ensuring that we keep our worker data reasonably up-to-date.
|
||
console.log(`${moment().format()} ⏰ Scheduled Workday sync skipped: Sync interval not elapsed`);
|
||
return { skipped: true };
|
||
}
|
||
return syncWorkers(null);
|
||
}
|
||
|
||
// Export the functions for use in other modules. This allows the Workday integration logic to be organized in a single module and reused across different parts of the application, such as in API endpoints or scheduled tasks for automatic synchronization.
|
||
module.exports = {
|
||
getConnection,
|
||
importWorkersFromPayload,
|
||
runScheduledSync,
|
||
saveConnection,
|
||
syncWorkers
|
||
};
|