Succeeded · just now · Run #DEMO-001
student-contact-export
End-to-end validation: verifies the pipeline runs successfully, writes to S3, and produces a complete, well-formed CSV file.
Test file: pipelines/student-contact-export.test_pipeline
Linked pipeline: ./student-contact-export.pipeline
Interactive simulation · Test definition adapted to this demo’s contact-export pipeline. No tenant calls or files are written.
Parameters
Connections
The linked pipeline supplies the Ethos and S3 connections. This preview uses simulated responses and needs no credentials.
Pipeline segments
Run the full pipeline or select segments to test. Each .test_pipeline file is a separate test case stored alongside its pipeline.
Verification steps
Verify successAssert · 3 validationsPassed
Pipeline executed without errors and produced output
errorsnot existsdatais arraydata[0].payloadis stringExtract CSV payloadJavaScript transformPassed
Extract the CSV string from execute envelope into chain payload for compareFile
function transform(message, context) {
const csv = message.payload && message.payload.data && message.payload.data[0]
? message.payload.data[0].payload : '';
message.payload = { csv: csv };
return message;
}compareFileCompare filePassed
Output CSV content matches the expected student contact export format and records
- Source
- csv
- Expected file
- ../validation/expected-output.csv
View expected CSV sample
bannerid,firstName,lastName,dateofbirth,email,phone,city,address,emergencyContact BAN001,John,Smith,1985-06-15,john.smith@example.edu,555-123-4567,Boston,123 Main Street Apt 4B,Jane Smith - 555-987-6543 BAN002,Mary,Johnson,1990-03-22,mary.j@email.com,,Portland,456 Oak Avenue Apt 201,
Synthetic preview sample; no student data.
contact-export
DRAFTSimulated Ellucian tenant · Publishing here does not affect any live environment.
- Name
- contact-export
- Version
- Not published
- Status
- DRAFT
- Type
- Pipeline
- Package
- demo-package
- Tenant
- Demo University
- Environment
- test-env
{
"name": "Student Contact Export",
"description": "Outbound extraction pipeline that creates a clean student contact file and uploads the CSV export to Amazon S3 for the campus outreach platform.",
"parameters": [
{
"name": "ethosApiKey",
"type": "string",
"label": "Ethos API Key",
"required": true,
"sensitive": true,
"persistent": true,
"description": "If using insights provide common Ethos API key else provide project specific Ethos API key"
},
{
"enum": [
{
"label": "SFTP with a Password",
"value": "outputSftpPassword"
},
{
"label": "SFTP with a Private Key",
"value": "outputSftpPrivateKey"
},
{
"label": "S3 Bucket",
"value": "outputS3"
},
{
"label": "Both S3 Bucket and SFTP with Private Key",
"value": "outputS3SftpPrivateKey"
},
{
"label": "Both S3 Bucket and SFTP with Password",
"value": "outputS3SftpPassword"
},
{
"label": "None of the above",
"value": "none"
}
],
"name": "fileSharingOption",
"type": "string",
"label": "File Sharing Option",
"required": true,
"persistent": true,
"description": "The dropdown to select the output file storage preference from S3, SFTP or Both S3 and SFTP and None of the above"
},
{
"name": "sftpUserName",
"type": "string",
"label": "Sftp User Name",
"persistent": true,
"description": "SFTP User Name"
},
{
"name": "sftpHostName",
"type": "string",
"label": "Sftp Host Name",
"persistent": true,
"description": "SFTP Host Name"
},
{
"name": "sftpPrivateKey",
"type": "string",
"label": "Sftp Private Key",
"multiline": true,
"sensitive": true,
"persistent": true,
"description": "SFTP Private Key"
},
{
"name": "sftpPassword",
"type": "string",
"label": "Sftp Password",
"sensitive": true,
"persistent": true,
"description": "SFTP Password"
},
{
"name": "s3AccessKey",
"type": "string",
"label": "S3 Access Key",
"persistent": true,
"description": "S3 Access Key"
},
{
"name": "s3SecretAccessKey",
"type": "string",
"label": "S3 Secret Access Key",
"sensitive": true,
"persistent": true,
"description": "S3 Secret Access Key"
},
{
"name": "outputS3Bucket",
"type": "string",
"label": "Output S3 Bucket",
"persistent": true,
"description": "S3 Bucket Name for storing output file. No directories should be added."
},
{
"name": "reportFilePath",
"type": "string",
"label": "Report File Path And Name",
"persistent": true,
"description": "File Path for Report File, should include file name without extension. Eg: main/report/FileNamePrefix (without file extension). Note: If \"File Sharing Option\" is 'None of the Above' and \"DC Preview\" is enabled, then provide only filename and not the whole file path."
},
{
"name": "auditFilePath",
"type": "string",
"default": "AuditLog",
"label": "Audit File Path And Name",
"persistent": true,
"description": "File Path for Audit File, should include file name without extension. Eg: main/audit/FileNamePrefix (without file extension). Note: If \"File Sharing Option\" is 'None of the Above' and \"DC Preview\" is enabled, then provide only filename and not the whole file path."
},
{
"name": "errorFilePath",
"type": "string",
"default": "ErrorLog",
"label": "Error File Path And Name",
"persistent": true,
"description": "File Path for Error File, should include file name without extension. Eg: main/error/FileNamePrefix (without file extension). Note: If \"File Sharing Option\" is 'None of the Above' and \"DC Preview\" is enabled, then provide only filename and not the whole file path."
},
{
"name": "dcPreview",
"type": "boolean",
"label": "DC Preview",
"persistent": true,
"description": "Preview files on DC dashboard"
},
{
"enum": [
{
"label": "Baseline",
"value": "baseline"
},
{
"label": "Custom",
"value": "custom"
},
{
"label": "Extend",
"value": "extend"
}
],
"name": "extractProcessMethod",
"type": "string",
"label": "Extract Process Method",
"default": "baseline",
"required": true,
"persistent": true,
"description": "Choose a data extract process method between Baseline/Custom/Extend"
},
{
"enum": [
{
"label": "Baseline",
"value": "baseline"
},
{
"label": "Custom",
"value": "custom"
},
{
"label": "Extend",
"value": "extend"
}
],
"name": "dataProcessingMethod",
"type": "string",
"label": "Data Processing Method",
"default": "baseline",
"required": true,
"persistent": true,
"description": "Choose a report file data process method between Baseline/Custom/Extend"
},
{
"enum": [
{
"label": "Baseline",
"value": "baseline"
},
{
"label": "Custom",
"value": "custom"
}
],
"name": "dataTransformMethod",
"type": "string",
"label": "Data Transform Method",
"default": "baseline",
"required": true,
"persistent": true,
"description": "Choose a report file data transform method between Baseline/Custom"
},
{
"enum": [
{
"label": "Baseline",
"value": "baseline"
},
{
"label": "Custom",
"value": "custom"
}
],
"name": "auditFileProcessMethod",
"type": "string",
"label": "Audit File Process Method",
"default": "baseline",
"required": true,
"persistent": true,
"description": "Choose an audit file data process method between Baseline/Custom"
},
{
"enum": [
{
"label": "Baseline",
"value": "baseline"
},
{
"label": "Custom",
"value": "custom"
}
],
"name": "errorFileProcessMethod",
"type": "string",
"label": "Error File Process Method",
"default": "baseline",
"required": true,
"persistent": true,
"description": "Choose an error file data process method between Baseline/Custom"
},
{
"name": "extendedParameters",
"type": "string",
"label": "Extended Parameters",
"default": "{\"sendAuditFile\":true, \"sendErrorFile\":true, \"noFile\":false, \"reportFileDelimiter\":\",\", \"reportFileExtension\":\"csv\", \"reportFileWithHeaders\":true}",
"multiline": true,
"description": "Additional parameter for pipeline, input should be in json format. Eg: {\"collCode\":\"value\", \"levlCode\": \"value\", \"noFile\":true, \"sendAuditFile\":true, \"sendErrorFile\":true, \"reportFileDelimiter\":\",\", \"reportFileExtension\":\"csv\", \"reportFileWithHeaders\":true}"
},
{
"name": "additionalCustomOperations",
"type": "boolean",
"label": "Additional Custom Operations",
"persistent": true,
"description": "Post processing logic to be applied here. Eg: Writing files to various location, sending email notification and etc."
},
{
"name": "dateOfBirthCutoff",
"type": "string",
"label": "Date of Birth Cutoff",
"default": "2000-01-01",
"description": "Records with dateOfBirth earlier than this value (YYYY-MM-DD format) are included in export. Default: 2000-01-01"
},
{
"required": true,
"description": "Target Amazon S3 bucket name where output CSV will be stored.",
"label": "S3 Bucket Name",
"name": "s3BucketName",
"type": "string"
},
{
"default": "student_contacts/output.csv",
"description": "Full S3 object key/path where output.csv will be stored. Must end with 'output.csv'. Default: student_contacts/output.csv",
"label": "S3 Key Path",
"name": "s3KeyPath",
"type": "string"
},
{
"default": "export",
"description": "Execution mode: 'validateOnly' tests configuration without exporting; 'export' performs full export. Default: export",
"enum": [
{
"label": "Validate Only",
"value": "validateOnly"
},
{
"label": "Export (Full)",
"value": "export"
}
],
"label": "Execution Mode",
"name": "executionMode",
"type": "string"
},
{
"description": "Optional batch identifier for traceability and logging. Auto-generated if not supplied.",
"label": "Batch Identifier",
"name": "batchIdentifier",
"type": "string"
}
],
"pipeline": [
"JS - Audit Trail v3.1.0",
"GET - installation-controls (Baseline)",
"JS - Fetch University or School Name (Baseline)",
"JS - Baseline validation setup (Baseline)",
"Sub-pipeline - Extensibility Baseline Validation (Baseline)",
"Sub-pipeline - Validate Credentials (Baseline)",
"Sub-pipeline - Baseline Parameter User Value Validation (Baseline)",
"GET - persons (Baseline)",
"JS - Filter and enrich persons by age (Baseline)",
"For Each - filtered persons (Baseline)",
"JS - Init loop gate (Baseline)",
"GET - names for person (Baseline)",
"GET - emails for person (Baseline)",
"GET - phones for person (Baseline)",
"GET - addresses for person (Baseline)",
"GET - emergency-contacts for person (Baseline)",
"JS - Apply best-record selection rules (Baseline)",
"Reducer - accumulate CSV rows (Baseline)",
"JS - No data extract handling (Baseline)",
"Sub-pipeline - Extract data (Custom/Extend)",
"JS - Format data series (Baseline)",
"JS - No data process handling (Baseline)",
"Sub-pipeline - Process data (Custom/Extend)",
"JS - Persist header for report file (Baseline)",
"Sub-pipeline - Transform Report File (Custom)",
"JS - Create data for file (Baseline)",
"JS - Add report file content (Baseline)",
"Sub-pipeline - Send Report file (Baseline)",
"JS - Persist header for Audit file (Baseline)",
"Sub-pipeline - Transform Audit File (Custom)",
"JS - Create Audit file content (Baseline)",
"JS - No data audit handling (Baseline)",
"Sub-pipeline - Send Audit file (Baseline)",
"JS - Persist header for Error file (Baseline)",
"Sub-pipeline - Transform Error file (Custom)",
"JS - Create error file content (Baseline)",
"Sub-pipeline - Send Error file (Baseline)",
"Sub-pipeline - Additional Custom Operations (Custom)",
"JS - Change pipeline execution status (Baseline)"
],
"segments": {
"JS - Audit Trail v3.1.0": {
"class": "JavaScriptTransform",
"description": "Pipeline details log",
"config": {
"pushUndefined": true,
"stopOnError": false,
"code": "function transform (message, context) {\n\n return message\n}"
}
},
"GET - installation-controls (Baseline)": {
"class": "ethosProxyGetFilter",
"description": "Get university name",
"config": {
"resource": "installation-controls",
"payloadTargetPath": "installationControlsResponse",
"acceptVersions": [
"1"
],
"cache": false,
"queryByPost": false,
"ignoreErrors": true,
"errorPath": "installationControlsError"
}
},
"JS - Fetch University or School Name (Baseline)": {
"class": "JavaScriptTransform",
"description": "Fetch University/School Name",
"config": {
"pushUndefined": true,
"stopOnError": false,
"code": "function transform (message, context) {\r\n if(message.payload?.installationControlsError){\r\n throw new Error(`installation-controls: ${generateErrorMessage(message.payload?.installationControlsError)}`);\r\n }\r\n else if(message.payload?.installationControlsResponse.length<1){\r\n throw new Error(`installation-controls: API returned no data`);\r\n }\r\n else{\r\n context.set(\"universityName\",message.payload?.installationControlsResponse?.[0]?.name);\r\n delete message.payload?.installationControlsResponse;\r\n }\r\n return message;\r\n}\r\n\r\nfunction generateErrorMessage(getError) {\r\n let err = \"\";\r\n if (getError?.message) {\r\n err += `${getError.message}`;\r\n }\r\n if (getError?.body && typeof getError?.body === \"object\") {\r\n const body = getError.body;\r\n \r\n const collect = (val) => {\r\n if (Array.isArray(val)) {\r\n return val.map(collect).join(\" \");\r\n }\r\n if (val && typeof val === \"object\") {\r\n return Object.values(val).map(collect).join(\" \");\r\n }\r\n return String(val ?? \"\");\r\n };\r\n \r\n for (let key in body) {\r\n if (Object.prototype.hasOwnProperty.call(body, key)) {\r\n err += ` ${collect(body[key])}`;\r\n }\r\n }\r\n } else if (getError?.body && typeof getError?.body !== \"object\") {\r\n err += ` ${getError.body}`;\r\n } else if (!getError?.message) {\r\n err += JSON.stringify(getError);\r\n }\r\n return err;\r\n}"
}
},
"JS - Baseline validation setup (Baseline)": {
"class": "JavaScriptTransform",
"description": "Setup up parameter for extensibility validation",
"config": {
"pushUndefined": true,
"stopOnError": false,
"code": "function transform (message, context) {\n let validateParam = [];\n let valueValidateParam = [];\n \n if((context.extractProcessMethod === \"baseline\" || context.extractProcessMethod === \"extend\")) {\n validateParam = ['termCode'];\n valueValidateParam = ['termCode'];\n // Valid pipeline parameter when Phase 1 and Phase 2 is not Custom Mode\n context.set('parameterValidationExists', true);\n \n context.set('pipelineNameValidation', true); //replace pipelineName with current pipeline name to pass parameter into the Sub-pipeline - Baseline Parameter User Value Validation (Baseline)\n }\n\n context.set('validationParameters', validateParam);\n context.set('valueValidationParameters', valueValidateParam);\n \n return message;\n}"
}
},
"Sub-pipeline - Extensibility Baseline Validation (Baseline)": {
"class": "SubPipeline",
"description": "Validation baseline and extensibility parameter requirement validation",
"config": {
"name": "Ellucian-Baseline-Validation-Sub",
"version": "1",
"isExtensible": false
}
},
"Sub-pipeline - Validate Credentials (Baseline)": {
"class": "SubPipeline",
"description": "Validate SFTP, S3 credentials and Ethos API Key",
"config": {
"name": "Ellucian-Credential-Validation-Sub",
"version": "1",
"isExtensible": false
}
},
"Sub-pipeline - Baseline Parameter User Value Validation (Baseline)": {
"class": "SubPipeline",
"description": "Baseline Parameter Value Validation of user input",
"if": "context.parameterValidationExists",
"config": {
"name": "",
"isExtensible": false
}
},
"GET - persons (Baseline)": {
"class": "ethosProxyGetFilter",
"description": "Fetch all person records from Banner with dateOfBirth populated for age filtering.",
"if": "context.extractBaseline",
"config": {
"resource": "persons?criteria={\"dateOfBirth\":{\"$ne\":null}}",
"payloadTargetPath": "personsResponse",
"acceptVersions": [
"12.0.0"
],
"cache": false,
"queryByPost": false,
"ignoreErrors": true,
"errorPath": "personsError"
}
},
"JS - Filter and enrich persons by age (Baseline)": {
"class": "JavaScriptTransform",
"config": {
"code": "function transform(message, context) {\n if (message.payload?.personsError) {\n throw new Error(`persons API Errored On: ${generateErrorMessage(message.payload.personsError)}`);\n }\n\n const persons = message.payload?.personsResponse || [];\n const dateOfBirthCutoff = context.dateOfBirthCutoff || '2000-01-01';\n const batchId = context.batchIdentifier || `batch-${new Date().getTime()}`;\n\n let evaluated = 0;\n let excluded_missing_dob = 0;\n let excluded_dob_after_cutoff = 0;\n const filtered = [];\n\n persons.forEach((person) => {\n evaluated++;\n const dob = person.dateOfBirth;\n\n if (!dob) {\n excluded_missing_dob++;\n return;\n }\n\n if (dob >= dateOfBirthCutoff) {\n excluded_dob_after_cutoff++;\n return;\n }\n\n // Person passes filter: has DOB and DOB < cutoff\n filtered.push({\n bannerId: person.id || '',\n dateOfBirth: dob,\n personObject: person // Keep original for names/contact API lookups\n });\n });\n\n // Log evaluation counts\n context.set('evaluatedPersonCount', evaluated);\n context.set('excludedMissingDob', excluded_missing_dob);\n context.set('excludedDobAfterCutoff', excluded_dob_after_cutoff);\n context.set('filteredPersonCount', filtered.length);\n context.set('batchId', batchId);\n context.set('dateOfBirthCutoff', dateOfBirthCutoff);\n context.set('executionMode', context.executionMode || 'export');\n\n // Set final payload for iteration over filtered persons\n message.payload = filtered;\n\n return message;\n}\n\nfunction generateErrorMessage(getError) {\n let err = '';\n if (getError?.message) {\n err += `${getError.message}`;\n }\n if (getError?.body && typeof getError?.body === 'object') {\n const body = getError.body;\n const collect = (val) => {\n if (Array.isArray(val)) return val.map(collect).join(' ');\n if (val && typeof val === 'object') return Object.values(val).map(collect).join(' ');\n return String(val ?? '');\n };\n for (let key in body) {\n if (Object.prototype.hasOwnProperty.call(body, key)) {\n err += ` ${collect(body[key])}`;\n }\n }\n } else if (getError?.body && typeof getError?.body !== 'object') {\n err += ` ${getError.body}`;\n } else if (!getError?.message) {\n err += JSON.stringify(getError);\n }\n return err;\n}",
"pushUndefined": true,
"stopOnError": true
},
"description": "Filter persons by age criteria (dateOfBirth < cutoff) and track evaluation counts. Payload becomes array of filtered person records with bannerId, dateOfBirth, and original person object.",
"if": "context.extractBaseline"
},
"For Each - filtered persons (Baseline)": {
"class": "forEach",
"config": {
"source": "payload"
},
"description": "Iterate over filtered person records to enrich with contact information.",
"if": "context.extractBaseline,message.header.proceedLoop"
},
"JS - Init loop gate (Baseline)": {
"class": "JavaScriptTransform",
"config": {
"code": "function transform(message, context) {\n // Precompute loop gate from filtered persons array\n message.header.proceedLoop = Array.isArray(message.payload) && message.payload.length > 0;\n return message;\n}",
"pushUndefined": true,
"stopOnError": false
},
"description": "Precompute loop gate before forEach based on filtered persons array length.",
"if": "context.extractBaseline"
},
"GET - names for person (Baseline)": {
"class": "ethosProxyGetFilter",
"config": {
"acceptVersions": [
"12.0.0"
],
"cache": false,
"errorPath": "namesError",
"ignoreErrors": true,
"payloadTargetPath": "namesResponse",
"queryByPost": false,
"resource": "names?criteria={\"personId\":\"{{{message.payload.bannerId}}}\"}"
},
"description": "Retrieve all name records for current person to apply best-record selection logic.",
"if": "context.extractBaseline,message.header.proceedLoop"
},
"GET - emails for person (Baseline)": {
"class": "ethosProxyGetFilter",
"config": {
"acceptVersions": [
"12.0.0"
],
"cache": false,
"errorPath": "emailsError",
"ignoreErrors": true,
"payloadTargetPath": "emailsResponse",
"queryByPost": false,
"resource": "emails?criteria={\"personId\":\"{{{message.payload.bannerId}}}\"}"
},
"description": "Retrieve all email records for current person to apply best-record selection (primary > lowest priority > any available).",
"if": "context.extractBaseline,message.header.proceedLoop"
},
"GET - phones for person (Baseline)": {
"class": "ethosProxyGetFilter",
"config": {
"acceptVersions": [
"12.0.0"
],
"cache": false,
"errorPath": "phonesError",
"ignoreErrors": true,
"payloadTargetPath": "phonesResponse",
"queryByPost": false,
"resource": "phones?criteria={\"personId\":\"{{{message.payload.bannerId}}}\"}"
},
"description": "Retrieve all phone records for current person to apply best-record selection (primary > lowest priority > any available).",
"if": "context.extractBaseline,message.header.proceedLoop"
},
"GET - addresses for person (Baseline)": {
"class": "ethosProxyGetFilter",
"config": {
"acceptVersions": [
"12.0.0"
],
"cache": false,
"errorPath": "addressesError",
"ignoreErrors": true,
"payloadTargetPath": "addressesResponse",
"queryByPost": false,
"resource": "addresses?criteria={\"personId\":\"{{{message.payload.bannerId}}}\"}"
},
"description": "Retrieve all address records for current person to apply best-record selection (active mailing > active permanent > highest-priority active > any available).",
"if": "context.extractBaseline,message.header.proceedLoop"
},
"GET - emergency-contacts for person (Baseline)": {
"class": "ethosProxyGetFilter",
"config": {
"acceptVersions": [
"12.0.0"
],
"cache": false,
"errorPath": "emergencyContactsError",
"ignoreErrors": true,
"payloadTargetPath": "emergencyContactsResponse",
"queryByPost": false,
"resource": "emergency-contacts?criteria={\"personId\":\"{{{message.payload.bannerId}}}\"}"
},
"description": "Retrieve all emergency contact records for current person to apply best-record selection (primary > lowest priority > any available).",
"if": "context.extractBaseline,message.header.proceedLoop"
},
"JS - Apply best-record selection rules (Baseline)": {
"class": "JavaScriptTransform",
"config": {
"code": "function transform(message, context) {\n // Build output row for current person with best-record selections\n const person = message.payload;\n const names = message.payload?.namesResponse || [];\n const emails = message.payload?.emailsResponse || [];\n const phones = message.payload?.phonesResponse || [];\n const addresses = message.payload?.addressesResponse || [];\n const emergencyContacts = message.payload?.emergencyContactsResponse || [];\n\n // Select best name: preferred > legal > any\n const selectName = () => {\n const preferred = names.find(n => n.type === 'preferred' || n.nameType === 'preferred');\n if (preferred) return { first: preferred.firstName || '', last: preferred.lastName || '' };\n const legal = names.find(n => n.type === 'legal' || n.nameType === 'legal' || n.type === 'official');\n if (legal) return { first: legal.firstName || '', last: legal.lastName || '' };\n if (names.length > 0) return { first: names[0].firstName || '', last: names[0].lastName || '' };\n return { first: '', last: '' };\n };\n\n // Select best email: primary > lowest priority > any\n const selectEmail = () => {\n const primaries = emails.filter(e => e.isPrimary === true);\n if (primaries.length === 1) return primaries[0].address || '';\n if (primaries.length > 1) {\n const sorted = primaries.sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999));\n return sorted[0].address || '';\n }\n if (emails.length > 0) {\n const sorted = emails.sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999));\n return sorted[0].address || '';\n }\n return '';\n };\n\n // Select best phone: primary > lowest priority > any\n const selectPhone = () => {\n const primaries = phones.filter(p => p.isPrimary === true);\n if (primaries.length === 1) return primaries[0].number || '';\n if (primaries.length > 1) {\n const sorted = primaries.sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999));\n return sorted[0].number || '';\n }\n if (phones.length > 0) {\n const sorted = phones.sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999));\n return sorted[0].number || '';\n }\n return '';\n };\n\n // Select best address: active mailing > active permanent > highest-priority active > any\n const selectAddress = () => {\n const activeMailing = addresses.find(a => \n (a.type === 'mailing' || a.addressType === 'mailing') && \n (a.isActive === true || a.isCurrent === true)\n );\n if (activeMailing) {\n const streets = [activeMailing.addressLine1, activeMailing.addressLine2, activeMailing.addressLine3]\n .filter(l => l).join(' ');\n return { city: activeMailing.city || '', address: streets };\n }\n const activePermanent = addresses.find(a => \n (a.type === 'permanent' || a.addressType === 'permanent') && \n (a.isActive === true || a.isCurrent === true)\n );\n if (activePermanent) {\n const streets = [activePermanent.addressLine1, activePermanent.addressLine2, activePermanent.addressLine3]\n .filter(l => l).join(' ');\n return { city: activePermanent.city || '', address: streets };\n }\n const activeAddrs = addresses.filter(a => a.isActive === true || a.isCurrent === true);\n if (activeAddrs.length > 0) {\n const sorted = activeAddrs.sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999));\n const addr = sorted[0];\n const streets = [addr.addressLine1, addr.addressLine2, addr.addressLine3]\n .filter(l => l).join(' ');\n return { city: addr.city || '', address: streets };\n }\n if (addresses.length > 0) {\n const addr = addresses[0];\n const streets = [addr.addressLine1, addr.addressLine2, addr.addressLine3]\n .filter(l => l).join(' ');\n return { city: addr.city || '', address: streets };\n }\n return { city: '', address: '' };\n };\n\n // Select best emergency contact: primary > lowest priority > any\n const selectEmergencyContact = () => {\n const primaries = emergencyContacts.filter(ec => ec.isPrimary === true);\n const selectOne = (arr) => {\n if (arr.length === 0) return null;\n if (arr.length === 1) return arr[0];\n const sorted = arr.sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999));\n return sorted[0];\n };\n const selected = selectOne(primaries) || selectOne(emergencyContacts);\n if (!selected) return '';\n const name = selected.name || selected.contactName || '';\n const phone = selected.phone || selected.contactPhone || '';\n if (name && phone) return `${name} - ${phone}`;\n if (name) return name;\n if (phone) return phone;\n return '';\n };\n\n const nameData = selectName();\n const emailData = selectEmail();\n const phoneData = selectPhone();\n const addressData = selectAddress();\n const emergencyContactData = selectEmergencyContact();\n\n // Build CSV row\n message.payload = {\n bannerid: person.bannerId,\n firstName: nameData.first,\n lastName: nameData.last,\n dateofbirth: person.dateOfBirth,\n email: emailData,\n phone: phoneData,\n city: addressData.city,\n address: addressData.address,\n emergencyContact: emergencyContactData\n };\n\n return message;\n}",
"pushUndefined": true,
"stopOnError": false
},
"description": "Apply best-record selection logic for names, emails, phones, addresses, and emergency contacts. Build CSV output row with selected values.",
"if": "context.extractBaseline,message.header.proceedLoop"
},
"Reducer - accumulate CSV rows (Baseline)": {
"class": "reducer",
"config": {
"accumulator": "payload"
},
"description": "Accumulate individual CSV row objects back into a single array for CSV file assembly.",
"if": "context.extractBaseline,message.header.proceedLoop"
},
"JS - No data extract handling (Baseline)": {
"class": "JavaScriptTransform",
"description": "Create output for extract phase when no data",
"if": "context.extractBaseline",
"config": {
"pushUndefined": true,
"stopOnError": false,
"draft": false,
"code": "function transform (message, context) {\n \n if (!Array.isArray(message.payload) || message.payload?.length === 0){\n //throw new Error(`Reading operation returned no data`);\n context.set('doCustomAudit', true);\n context.set('customAuditContent', 'Reading operation returned no data')\n message.payload = [];\n } else if (message.payload.length > 0){\n context.set(\"existDataProcess\", true);\n }\n \n return message;\n}\n"
}
},
"Sub-pipeline - Extract data (Custom/Extend)": {
"class": "SubPipeline",
"description": "Extract data from subpipeline",
"if": "context.extractCustom",
"config": {
"isExtensible": true
}
},
"JS - Format data series (Baseline)": {
"class": "JavaScriptTransform",
"config": {
"code": "function transform(message, context) {\n const rows = Array.isArray(message.payload) ? message.payload : [];\n \n // Build CSV header\n const header = 'bannerid,firstName,lastName,dateofbirth,email,phone,city,address,emergencyContact';\n \n // Escape and quote CSV fields\n const escapeCsvField = (value) => {\n if (value === null || value === undefined) return '';\n const str = String(value);\n if (str.includes(',') || str.includes('\"') || str.includes('\\n')) {\n return `\"${str.replace(/\"/g, '\"\"')}\"`;\n }\n return str;\n };\n \n // Build data rows\n const csvLines = [header];\n rows.forEach(row => {\n const line = [\n escapeCsvField(row.bannerid),\n escapeCsvField(row.firstName),\n escapeCsvField(row.lastName),\n escapeCsvField(row.dateofbirth),\n escapeCsvField(row.email),\n escapeCsvField(row.phone),\n escapeCsvField(row.city),\n escapeCsvField(row.address),\n escapeCsvField(row.emergencyContact)\n ].join(',');\n csvLines.push(line);\n });\n \n // Join with CRLF\n const csvContent = csvLines.join('\\r\\n');\n \n // Store CSV in payload and logs in context\n message.payload = {\n reportLog: [{\n output_csv: csvContent,\n record_count: rows.length,\n batch_id: context.batchId,\n execution_mode: context.executionMode\n }],\n auditLog: [{\n activity: 'CSV File Generation',\n message: `CSV file generated with ${rows.length} data rows (plus 1 header row).`,\n timestamp: new Date().toISOString(),\n batch_id: context.batchId\n }],\n errorLog: []\n };\n \n context.set('csvContent', csvContent);\n context.set('outputRecordCount', rows.length);\n context.set('existProcess', rows.length > 0);\n \n return message;\n}",
"pushUndefined": true,
"stopOnError": false
},
"description": "Format accumulated CSV rows with header and proper quoting. Assemble final CSV content for output."
},
"JS - No data process handling (Baseline)": {
"class": "JavaScriptTransform",
"description": "Create output for process phase when no data",
"if": "context.processBaseline,context.doCustomAudit",
"config": {
"pushUndefined": true,
"stopOnError": false,
"draft": false,
"code": "function transform (message, context) {\n context.set('existProcess', true);\n \n message.payload = {\n reportLog: [],\n auditLog: [],\n errorLog: []\n }\n \n return message;\n}"
}
},
"Sub-pipeline - Process data (Custom/Extend)": {
"class": "SubPipeline",
"description": "Transform extracted data for report file",
"if": "context.processCustom",
"config": {
"isExtensible": true
}
},
"JS - Persist header for report file (Baseline)": {
"class": "JavaScriptTransform",
"description": "Persist payload in header",
"if": "context.existProcess",
"config": {
"pushUndefined": true,
"stopOnError": false,
"code": "function transform (message, context) {\n message.header.fileLogs = message.payload;\n return message;\n}"
}
},
"Sub-pipeline - Transform Report File (Custom)": {
"class": "SubPipeline",
"description": "Transform report file data",
"if": "context.transformCustom",
"config": {
"isExtensible": true
}
},
"JS - Create data for file (Baseline)": {
"class": "JavaScriptTransform",
"config": {
"code": "function transform(message, context) {\n // Prepare final payload for S3 upload (CSV content at root)\n message.payload = context.csvContent || '';\n return message;\n}",
"pushUndefined": true,
"stopOnError": false
},
"description": "Prepare final payload for S3 upload by placing CSV content at root of message.payload."
},
"JS - Add report file content (Baseline)": {
"class": "JavaScriptTransform",
"config": {
"code": "function transform(message, context) {\n // Placeholder for report file content transformation (handled in Send Report File sub-pipeline)\n return message;\n}",
"pushUndefined": true,
"stopOnError": false
},
"description": "Placeholder for report file content processing."
},
"Sub-pipeline - Send Report file (Baseline)": {
"class": "SubPipeline",
"description": "Send Report file",
"if": "context.existReport",
"config": {
"name": "Ellucian-Report-File-Send-Sub",
"isExtensible": false
}
},
"JS - Persist header for Audit file (Baseline)": {
"class": "JavaScriptTransform",
"description": "Persist header in payload",
"if": "context.existProcess",
"config": {
"pushUndefined": true,
"stopOnError": false,
"code": "function transform (message, context) {\n message.payload = message.header.fileLogs;\n return message;\n}"
}
},
"Sub-pipeline - Transform Audit File (Custom)": {
"class": "SubPipeline",
"description": "Transform audit file data",
"if": "context.auditCustom",
"config": {
"isExtensible": true
}
},
"JS - Create Audit file content (Baseline)": {
"class": "JavaScriptTransform",
"description": "Create content for audit file",
"if": "context.auditBaseline",
"config": {
"pushUndefined": true,
"stopOnError": false,
"code": "function transform(message, context) {\n const reportLog = message.payload?.reportLog || [];\n const auditLog = message.payload?.auditLog || [];\n const errorLog = message.payload?.errorLog || [];\n const totalErrors = errorLog?.length;\n const totalProcessed = reportLog?.length;\n const totalRecords = auditLog?.length\n \n if(auditLog?.length > 0 && context.sendAuditFile){\n context.set(\"existAudit\", true);\n \n const universityName = context.universityName || \"Unknown University\";\n const projectName = context.get(\"__pipelineName\") || \"Project Name\";\n const todayDate = getFormattedDate('YYYYMMDD_HHmmss')\n const pipelineParameters = Array.isArray(context.parameters) ? context.parameters : [];\n const data = auditLog;\n\n // Format the pipeline parameters safely\n const parameterContent = pipelineParameters?.[0] ? \n Object.entries(pipelineParameters[0]).map(([key, value]) => `${key} : ${value}`).join('\\n') : \n \"\";\n \n const pad = (str, length) => (str || \"\")?.toString()?.padEnd(length, \" \");\n let headers = [];\n const columnWidths = {};\n headers = Object.keys(data[0]);\n const readableHeaders = headers.map((header) => toReadableHeader(header));\n\n // Initialize column widths with header lengths\n readableHeaders.forEach((header, index) => {\n columnWidths[headers[index]] = header.length + 2;\n });\n\n data?.forEach((log) => {\n headers.forEach((header) => {\n const valueLength = log[header] ? String(log[header]).length : 0;\n columnWidths[header] = Math.max(\n columnWidths[header],\n valueLength + 2\n );\n });\n });\n\n // Create header row\n const headerRow = readableHeaders\n .map((header, index) => pad(header, columnWidths[headers[index]]))\n .join(\"\");\n\n // Create dynamic row generator\n const createRow = (log) => {\n return headers\n .map((key) => pad(log[key], columnWidths[key]))\n .join(\"\");\n };\n \n const auditLogContent = `${getFormattedDate()} ${universityName}\n ${projectName} \n\nPipeline Parameters\n${parameterContent}\n\n${headerRow}\n${data?.map((log) => createRow(log)).join(\"\\n\")}\n\nTotal Records Processed : ${totalRecords}\nTotal Records Successful: ${totalProcessed || 0}\nTotal Records Failed: ${totalErrors || 0}`;\n\n const path = context.auditFilePath.split('/');\n message.payload = {};\n message.payload.sendFileName = `${path[path.length-1]}_${todayDate}.txt`;\n message.payload.fileContent = auditLogContent;\n }\n return message;\n}\n\nfunction getFormattedDate(format = 'MM-DD-YYYY HH:mm:ss') {\n const currentDate = new Date();\n const year = currentDate.getFullYear();\n const month = (currentDate.getMonth() + 1).toString().padStart(2, '0');\n const day = currentDate.getDate().toString().padStart(2, '0');\n const hours = currentDate.getHours().toString().padStart(2, '0');\n const minutes = currentDate.getMinutes().toString().padStart(2, '0');\n const seconds = currentDate.getSeconds().toString().padStart(2, '0');\n\n if (format === 'YYYYMMDD_HHmmss') {\n return `${year}${month}${day}_${hours}${minutes}${seconds}`;\n }\n\n return `${month}-${day}-${year} ${hours}:${minutes}:${seconds}`;\n}\n\nfunction toReadableHeader(key) {\n return key\n .replace(/_/g, ' ')\n .replace(/([a-z])([A-Z])/g, '$1 $2')\n .replace(/\\b\\w/g, char => char.toUpperCase());\n}\n"
}
},
"JS - No data audit handling (Baseline)": {
"class": "JavaScriptTransform",
"description": "Create output for audit phase when no data",
"if": "context.doCustomAudit",
"config": {
"pushUndefined": true,
"stopOnError": false,
"code": "function transform (message, context) {\n const path = context.get(\"auditFilePath\").split('/');\n context.set(\"existAudit\",true);\n message.payload = {}\n message.payload.sendFileName =`${path[path.length-1]}_${getFormattedDateTime()}.txt`;\n message.payload.fileContent = context.customAuditContent;\n \n return message;\n}\n\nfunction getFormattedDateTime() {\n const now = new Date();\n\n const pad = (num) => String(num).padStart(2, '0');\n\n const year = now.getFullYear();\n const month = pad(now.getMonth() + 1);\n const day = pad(now.getDate());\n const hours = pad(now.getHours());\n const minutes = pad(now.getMinutes());\n const seconds = pad(now.getSeconds());\n\n return `${year}${month}${day}_${hours}${minutes}${seconds}`;\n}"
}
},
"Sub-pipeline - Send Audit file (Baseline)": {
"class": "SubPipeline",
"description": "Send Audit file to S3/SFTP",
"if": "context.existAudit,context.sendAuditFile",
"config": {
"name": "Ellucian-Audit-File-Send-Sub",
"isExtensible": false
}
},
"JS - Persist header for Error file (Baseline)": {
"class": "JavaScriptTransform",
"description": "Persist header in payload",
"if": "context.existAudit",
"config": {
"pushUndefined": true,
"stopOnError": false,
"code": "function transform (message, context) {\n message.payload = message.header.fileLogs;\n return message;\n}"
}
},
"Sub-pipeline - Transform Error file (Custom)": {
"class": "SubPipeline",
"description": "Transform Error File data",
"if": "context.errorCustom",
"config": {
"isExtensible": true
}
},
"JS - Create error file content (Baseline)": {
"class": "JavaScriptTransform",
"description": "Create content for Error file",
"if": "context.errorBaseline",
"config": {
"pushUndefined": true,
"stopOnError": false,
"code": "function transform(message, context) {\n const errorLog = message.payload?.errorLog || [];\n \n if(errorLog?.length > 0 && context.sendErrorFile){\n const totalErrors = errorLog.length;\n \n context.set(\"errorCount\", totalErrors);\n context.set(\"existError\", true);\n context.set(\"pipelineError\", true);\n \n const universityName = context.universityName || \"Unknown University\";\n const projectName = context.get(\"__pipelineName\") || \"Project Name\";\n const todayDate = getFormattedDate('YYYYMMDD_HHmmss')\n const data = errorLog;\n\n const pad = (str, length) => (str || \"\")?.toString()?.padEnd(length, \" \");\n \n let headers = [];\n const columnWidths = {};\n headers = Object.keys(data[0]);\n const readableHeaders = headers.map((header) => toReadableHeader(header));\n \n // Initialize column widths with header lengths\n readableHeaders.forEach((header, index) => {\n columnWidths[headers[index]] = header.length + 2;\n });\n \n data?.forEach((log) => {\n headers.forEach((header) => {\n const valueLength = log[header] ? String(log[header]).length : 0;\n columnWidths[header] = Math.max(\n columnWidths[header],\n valueLength + 2\n );\n });\n });\n\n // Create header row\n const headerRow = readableHeaders\n .map((header, index) => pad(header, columnWidths[headers[index]]))\n .join(\"\");\n\n // Create dynamic row generator\n const createRow = (log) => {\n return headers\n .map((key) => pad(log[key], columnWidths[key]))\n .join(\"\");\n };\n \n const errorLogContent = `${getFormattedDate()} ${universityName} \n ${projectName} \n\n\n${headerRow}\n${data?.map((log) => createRow(log)).join(\"\\n\")}\n\nTotal Records Failed: ${totalErrors}`;\n \n const path = context.errorFilePath.split('/');\n message.payload = {};\n message.payload.sendFileName = `${path[path.length-1]}_${todayDate}.txt`;\n message.payload.fileContent = errorLogContent;\n }\n return message;\n}\n\nfunction getFormattedDate(format = 'MM-DD-YYYY HH:mm:ss') {\n const currentDate = new Date();\n const year = currentDate.getFullYear();\n const month = (currentDate.getMonth() + 1).toString().padStart(2, '0');\n const day = currentDate.getDate().toString().padStart(2, '0');\n const hours = currentDate.getHours().toString().padStart(2, '0');\n const minutes = currentDate.getMinutes().toString().padStart(2, '0');\n const seconds = currentDate.getSeconds().toString().padStart(2, '0');\n\n if (format === 'YYYYMMDD_HHmmss') {\n return `${year}${month}${day}_${hours}${minutes}${seconds}`;\n }\n\n return `${month}-${day}-${year} ${hours}:${minutes}:${seconds}`;\n}\n\nfunction toReadableHeader(key) {\n return key\n .replace(/_/g, ' ')\n .replace(/([a-z])([A-Z])/g, '$1 $2')\n .replace(/\\b\\w/g, char => char.toUpperCase());\n}"
}
},
"Sub-pipeline - Send Error file (Baseline)": {
"class": "SubPipeline",
"description": "Send error File file to S3/SFTP",
"if": "context.existError,context.sendErrorFile",
"config": {
"name": "Ellucian-Error-File-Send-Sub",
"isExtensible": false
}
},
"Sub-pipeline - Additional Custom Operations (Custom)": {
"class": "SubPipeline",
"description": "To do additional custom process",
"if": "context.additionalCustomOperations",
"config": {
"isExtensible": true
}
},
"JS - Change pipeline execution status (Baseline)": {
"class": "JavaScriptTransform",
"description": "Change status when error occurs",
"config": {
"pushUndefined": true,
"stopOnError": false,
"code": "function transform (message, context) {\n if(context.pipelineError) {\n throw new Error(\"Pipeline has been failed with errors, please review the error file for more details\");\n }\n return message;\n}"
}
}
}
}
Student Contact Export v0.0.1
PublishedPipeline overview
- Name
- Student Contact Export
- Version
- v0.0.1
- Status
- Published
- Type
- Pipeline
- Package
- demo-package
- Tenant
- Demo University
- Environment
- test-env
student-contact-export-job
- Status
- Ready
- Version
- v0.0.1
- Maximum concurrency
- 10
- HTTP request timeout
- 3 minutes
Run history
| Run ID | Version | Started | Duration | Status | Errors |
|---|---|---|---|---|---|
| #DEMO-002 | v0.0.1 | Today, 9:02 AM | 22 sec | Succeeded | 0 |
| #DEMO-001 | v0.0.1 | Today, 8:58 AM | 19 sec | Succeeded | 0 |
| #DEMO-SUN-001 | v0.0.1 | Sunday, 7:00 AM | 8 sec | Failed | 1 · S3 bucket unavailable |
Pipeline parameters
These values define the runtime configuration used by this job.
- S3 Bucket Name
- student-contact-bucket
- S3 Key Path
- student_contacts/output.csv
- Date of Birth Cutoff
- 2000-01-01
- Execution Mode
- export
- Batch Identifier
- batch-20260826-001
Pipeline JSON
{
"name": "Student Contact Export",
"description": "Outbound extraction pipeline that creates a clean student contact file and uploads the CSV export to Amazon S3 for the campus outreach platform.",
"parameters": [
{
"name": "ethosApiKey",
"type": "string",
"label": "Ethos API Key",
"required": true,
"sensitive": true,
"persistent": true,
"description": "If using insights provide common Ethos API key else provide project specific Ethos API key"
},
{
"enum": [
{
"label": "SFTP with a Password",
"value": "outputSftpPassword"
},
{
"label": "SFTP with a Private Key",
"value": "outputSftpPrivateKey"
},
{
"label": "S3 Bucket",
"value": "outputS3"
},
{
"label": "Both S3 Bucket and SFTP with Private Key",
"value": "outputS3SftpPrivateKey"
},
{
"label": "Both S3 Bucket and SFTP with Password",
"value": "outputS3SftpPassword"
},
{
"label": "None of the above",
"value": "none"
}
],
"name": "fileSharingOption",
"type": "string",
"label": "File Sharing Option",
"required": true,
"persistent": true,
"description": "The dropdown to select the output file storage preference from S3, SFTP or Both S3 and SFTP and None of the above"
},
{
"name": "sftpUserName",
"type": "string",
"label": "Sftp User Name",
"persistent": true,
"description": "SFTP User Name"
},
{
"name": "sftpHostName",
"type": "string",
"label": "Sftp Host Name",
"persistent": true,
"description": "SFTP Host Name"
},
{
"name": "sftpPrivateKey",
"type": "string",
"label": "Sftp Private Key",
"multiline": true,
"sensitive": true,
"persistent": true,
"description": "SFTP Private Key"
},
{
"name": "sftpPassword",
"type": "string",
"label": "Sftp Password",
"sensitive": true,
"persistent": true,
"description": "SFTP Password"
},
{
"name": "s3AccessKey",
"type": "string",
"label": "S3 Access Key",
"persistent": true,
"description": "S3 Access Key"
},
{
"name": "s3SecretAccessKey",
"type": "string",
"label": "S3 Secret Access Key",
"sensitive": true,
"persistent": true,
"description": "S3 Secret Access Key"
},
{
"name": "outputS3Bucket",
"type": "string",
"label": "Output S3 Bucket",
"persistent": true,
"description": "S3 Bucket Name for storing output file. No directories should be added."
},
{
"name": "reportFilePath",
"type": "string",
"label": "Report File Path And Name",
"persistent": true,
"description": "File Path for Report File, should include file name without extension. Eg: main/report/FileNamePrefix (without file extension). Note: If \"File Sharing Option\" is 'None of the Above' and \"DC Preview\" is enabled, then provide only filename and not the whole file path."
},
{
"name": "auditFilePath",
"type": "string",
"default": "AuditLog",
"label": "Audit File Path And Name",
"persistent": true,
"description": "File Path for Audit File, should include file name without extension. Eg: main/audit/FileNamePrefix (without file extension). Note: If \"File Sharing Option\" is 'None of the Above' and \"DC Preview\" is enabled, then provide only filename and not the whole file path."
},
{
"name": "errorFilePath",
"type": "string",
"default": "ErrorLog",
"label": "Error File Path And Name",
"persistent": true,
"description": "File Path for Error File, should include file name without extension. Eg: main/error/FileNamePrefix (without file extension). Note: If \"File Sharing Option\" is 'None of the Above' and \"DC Preview\" is enabled, then provide only filename and not the whole file path."
},
{
"name": "dcPreview",
"type": "boolean",
"label": "DC Preview",
"persistent": true,
"description": "Preview files on DC dashboard"
},
{
"enum": [
{
"label": "Baseline",
"value": "baseline"
},
{
"label": "Custom",
"value": "custom"
},
{
"label": "Extend",
"value": "extend"
}
],
"name": "extractProcessMethod",
"type": "string",
"label": "Extract Process Method",
"default": "baseline",
"required": true,
"persistent": true,
"description": "Choose a data extract process method between Baseline/Custom/Extend"
},
{
"enum": [
{
"label": "Baseline",
"value": "baseline"
},
{
"label": "Custom",
"value": "custom"
},
{
"label": "Extend",
"value": "extend"
}
],
"name": "dataProcessingMethod",
"type": "string",
"label": "Data Processing Method",
"default": "baseline",
"required": true,
"persistent": true,
"description": "Choose a report file data process method between Baseline/Custom/Extend"
},
{
"enum": [
{
"label": "Baseline",
"value": "baseline"
},
{
"label": "Custom",
"value": "custom"
}
],
"name": "dataTransformMethod",
"type": "string",
"label": "Data Transform Method",
"default": "baseline",
"required": true,
"persistent": true,
"description": "Choose a report file data transform method between Baseline/Custom"
},
{
"enum": [
{
"label": "Baseline",
"value": "baseline"
},
{
"label": "Custom",
"value": "custom"
}
],
"name": "auditFileProcessMethod",
"type": "string",
"label": "Audit File Process Method",
"default": "baseline",
"required": true,
"persistent": true,
"description": "Choose an audit file data process method between Baseline/Custom"
},
{
"enum": [
{
"label": "Baseline",
"value": "baseline"
},
{
"label": "Custom",
"value": "custom"
}
],
"name": "errorFileProcessMethod",
"type": "string",
"label": "Error File Process Method",
"default": "baseline",
"required": true,
"persistent": true,
"description": "Choose an error file data process method between Baseline/Custom"
},
{
"name": "extendedParameters",
"type": "string",
"label": "Extended Parameters",
"default": "{\"sendAuditFile\":true, \"sendErrorFile\":true, \"noFile\":false, \"reportFileDelimiter\":\",\", \"reportFileExtension\":\"csv\", \"reportFileWithHeaders\":true}",
"multiline": true,
"description": "Additional parameter for pipeline, input should be in json format. Eg: {\"collCode\":\"value\", \"levlCode\": \"value\", \"noFile\":true, \"sendAuditFile\":true, \"sendErrorFile\":true, \"reportFileDelimiter\":\",\", \"reportFileExtension\":\"csv\", \"reportFileWithHeaders\":true}"
},
{
"name": "additionalCustomOperations",
"type": "boolean",
"label": "Additional Custom Operations",
"persistent": true,
"description": "Post processing logic to be applied here. Eg: Writing files to various location, sending email notification and etc."
},
{
"name": "dateOfBirthCutoff",
"type": "string",
"label": "Date of Birth Cutoff",
"default": "2000-01-01",
"description": "Records with dateOfBirth earlier than this value (YYYY-MM-DD format) are included in export. Default: 2000-01-01"
},
{
"required": true,
"description": "Target Amazon S3 bucket name where output CSV will be stored.",
"label": "S3 Bucket Name",
"name": "s3BucketName",
"type": "string"
},
{
"default": "student_contacts/output.csv",
"description": "Full S3 object key/path where output.csv will be stored. Must end with 'output.csv'. Default: student_contacts/output.csv",
"label": "S3 Key Path",
"name": "s3KeyPath",
"type": "string"
},
{
"default": "export",
"description": "Execution mode: 'validateOnly' tests configuration without exporting; 'export' performs full export. Default: export",
"enum": [
{
"label": "Validate Only",
"value": "validateOnly"
},
{
"label": "Export (Full)",
"value": "export"
}
],
"label": "Execution Mode",
"name": "executionMode",
"type": "string"
},
{
"description": "Optional batch identifier for traceability and logging. Auto-generated if not supplied.",
"label": "Batch Identifier",
"name": "batchIdentifier",
"type": "string"
}
],
"pipeline": [
"JS - Audit Trail v3.1.0",
"GET - installation-controls (Baseline)",
"JS - Fetch University or School Name (Baseline)",
"JS - Baseline validation setup (Baseline)",
"Sub-pipeline - Extensibility Baseline Validation (Baseline)",
"Sub-pipeline - Validate Credentials (Baseline)",
"Sub-pipeline - Baseline Parameter User Value Validation (Baseline)",
"GET - persons (Baseline)",
"JS - Filter and enrich persons by age (Baseline)",
"For Each - filtered persons (Baseline)",
"JS - Init loop gate (Baseline)",
"GET - names for person (Baseline)",
"GET - emails for person (Baseline)",
"GET - phones for person (Baseline)",
"GET - addresses for person (Baseline)",
"GET - emergency-contacts for person (Baseline)",
"JS - Apply best-record selection rules (Baseline)",
"Reducer - accumulate CSV rows (Baseline)",
"JS - No data extract handling (Baseline)",
"Sub-pipeline - Extract data (Custom/Extend)",
"JS - Format data series (Baseline)",
"JS - No data process handling (Baseline)",
"Sub-pipeline - Process data (Custom/Extend)",
"JS - Persist header for report file (Baseline)",
"Sub-pipeline - Transform Report File (Custom)",
"JS - Create data for file (Baseline)",
"JS - Add report file content (Baseline)",
"Sub-pipeline - Send Report file (Baseline)",
"JS - Persist header for Audit file (Baseline)",
"Sub-pipeline - Transform Audit File (Custom)",
"JS - Create Audit file content (Baseline)",
"JS - No data audit handling (Baseline)",
"Sub-pipeline - Send Audit file (Baseline)",
"JS - Persist header for Error file (Baseline)",
"Sub-pipeline - Transform Error file (Custom)",
"JS - Create error file content (Baseline)",
"Sub-pipeline - Send Error file (Baseline)",
"Sub-pipeline - Additional Custom Operations (Custom)",
"JS - Change pipeline execution status (Baseline)"
],
"segments": {
"JS - Audit Trail v3.1.0": {
"class": "JavaScriptTransform",
"description": "Pipeline details log",
"config": {
"pushUndefined": true,
"stopOnError": false,
"code": "function transform (message, context) {\n\n return message\n}"
}
},
"GET - installation-controls (Baseline)": {
"class": "ethosProxyGetFilter",
"description": "Get university name",
"config": {
"resource": "installation-controls",
"payloadTargetPath": "installationControlsResponse",
"acceptVersions": [
"1"
],
"cache": false,
"queryByPost": false,
"ignoreErrors": true,
"errorPath": "installationControlsError"
}
},
"JS - Fetch University or School Name (Baseline)": {
"class": "JavaScriptTransform",
"description": "Fetch University/School Name",
"config": {
"pushUndefined": true,
"stopOnError": false,
"code": "function transform (message, context) {\r\n if(message.payload?.installationControlsError){\r\n throw new Error(`installation-controls: ${generateErrorMessage(message.payload?.installationControlsError)}`);\r\n }\r\n else if(message.payload?.installationControlsResponse.length<1){\r\n throw new Error(`installation-controls: API returned no data`);\r\n }\r\n else{\r\n context.set(\"universityName\",message.payload?.installationControlsResponse?.[0]?.name);\r\n delete message.payload?.installationControlsResponse;\r\n }\r\n return message;\r\n}\r\n\r\nfunction generateErrorMessage(getError) {\r\n let err = \"\";\r\n if (getError?.message) {\r\n err += `${getError.message}`;\r\n }\r\n if (getError?.body && typeof getError?.body === \"object\") {\r\n const body = getError.body;\r\n \r\n const collect = (val) => {\r\n if (Array.isArray(val)) {\r\n return val.map(collect).join(\" \");\r\n }\r\n if (val && typeof val === \"object\") {\r\n return Object.values(val).map(collect).join(\" \");\r\n }\r\n return String(val ?? \"\");\r\n };\r\n \r\n for (let key in body) {\r\n if (Object.prototype.hasOwnProperty.call(body, key)) {\r\n err += ` ${collect(body[key])}`;\r\n }\r\n }\r\n } else if (getError?.body && typeof getError?.body !== \"object\") {\r\n err += ` ${getError.body}`;\r\n } else if (!getError?.message) {\r\n err += JSON.stringify(getError);\r\n }\r\n return err;\r\n}"
}
},
"JS - Baseline validation setup (Baseline)": {
"class": "JavaScriptTransform",
"description": "Setup up parameter for extensibility validation",
"config": {
"pushUndefined": true,
"stopOnError": false,
"code": "function transform (message, context) {\n let validateParam = [];\n let valueValidateParam = [];\n \n if((context.extractProcessMethod === \"baseline\" || context.extractProcessMethod === \"extend\")) {\n validateParam = ['termCode'];\n valueValidateParam = ['termCode'];\n // Valid pipeline parameter when Phase 1 and Phase 2 is not Custom Mode\n context.set('parameterValidationExists', true);\n \n context.set('pipelineNameValidation', true); //replace pipelineName with current pipeline name to pass parameter into the Sub-pipeline - Baseline Parameter User Value Validation (Baseline)\n }\n\n context.set('validationParameters', validateParam);\n context.set('valueValidationParameters', valueValidateParam);\n \n return message;\n}"
}
},
"Sub-pipeline - Extensibility Baseline Validation (Baseline)": {
"class": "SubPipeline",
"description": "Validation baseline and extensibility parameter requirement validation",
"config": {
"name": "Ellucian-Baseline-Validation-Sub",
"version": "1",
"isExtensible": false
}
},
"Sub-pipeline - Validate Credentials (Baseline)": {
"class": "SubPipeline",
"description": "Validate SFTP, S3 credentials and Ethos API Key",
"config": {
"name": "Ellucian-Credential-Validation-Sub",
"version": "1",
"isExtensible": false
}
},
"Sub-pipeline - Baseline Parameter User Value Validation (Baseline)": {
"class": "SubPipeline",
"description": "Baseline Parameter Value Validation of user input",
"if": "context.parameterValidationExists",
"config": {
"name": "",
"isExtensible": false
}
},
"GET - persons (Baseline)": {
"class": "ethosProxyGetFilter",
"description": "Fetch all person records from Banner with dateOfBirth populated for age filtering.",
"if": "context.extractBaseline",
"config": {
"resource": "persons?criteria={\"dateOfBirth\":{\"$ne\":null}}",
"payloadTargetPath": "personsResponse",
"acceptVersions": [
"12.0.0"
],
"cache": false,
"queryByPost": false,
"ignoreErrors": true,
"errorPath": "personsError"
}
},
"JS - Filter and enrich persons by age (Baseline)": {
"class": "JavaScriptTransform",
"config": {
"code": "function transform(message, context) {\n if (message.payload?.personsError) {\n throw new Error(`persons API Errored On: ${generateErrorMessage(message.payload.personsError)}`);\n }\n\n const persons = message.payload?.personsResponse || [];\n const dateOfBirthCutoff = context.dateOfBirthCutoff || '2000-01-01';\n const batchId = context.batchIdentifier || `batch-${new Date().getTime()}`;\n\n let evaluated = 0;\n let excluded_missing_dob = 0;\n let excluded_dob_after_cutoff = 0;\n const filtered = [];\n\n persons.forEach((person) => {\n evaluated++;\n const dob = person.dateOfBirth;\n\n if (!dob) {\n excluded_missing_dob++;\n return;\n }\n\n if (dob >= dateOfBirthCutoff) {\n excluded_dob_after_cutoff++;\n return;\n }\n\n // Person passes filter: has DOB and DOB < cutoff\n filtered.push({\n bannerId: person.id || '',\n dateOfBirth: dob,\n personObject: person // Keep original for names/contact API lookups\n });\n });\n\n // Log evaluation counts\n context.set('evaluatedPersonCount', evaluated);\n context.set('excludedMissingDob', excluded_missing_dob);\n context.set('excludedDobAfterCutoff', excluded_dob_after_cutoff);\n context.set('filteredPersonCount', filtered.length);\n context.set('batchId', batchId);\n context.set('dateOfBirthCutoff', dateOfBirthCutoff);\n context.set('executionMode', context.executionMode || 'export');\n\n // Set final payload for iteration over filtered persons\n message.payload = filtered;\n\n return message;\n}\n\nfunction generateErrorMessage(getError) {\n let err = '';\n if (getError?.message) {\n err += `${getError.message}`;\n }\n if (getError?.body && typeof getError?.body === 'object') {\n const body = getError.body;\n const collect = (val) => {\n if (Array.isArray(val)) return val.map(collect).join(' ');\n if (val && typeof val === 'object') return Object.values(val).map(collect).join(' ');\n return String(val ?? '');\n };\n for (let key in body) {\n if (Object.prototype.hasOwnProperty.call(body, key)) {\n err += ` ${collect(body[key])}`;\n }\n }\n } else if (getError?.body && typeof getError?.body !== 'object') {\n err += ` ${getError.body}`;\n } else if (!getError?.message) {\n err += JSON.stringify(getError);\n }\n return err;\n}",
"pushUndefined": true,
"stopOnError": true
},
"description": "Filter persons by age criteria (dateOfBirth < cutoff) and track evaluation counts. Payload becomes array of filtered person records with bannerId, dateOfBirth, and original person object.",
"if": "context.extractBaseline"
},
"For Each - filtered persons (Baseline)": {
"class": "forEach",
"config": {
"source": "payload"
},
"description": "Iterate over filtered person records to enrich with contact information.",
"if": "context.extractBaseline,message.header.proceedLoop"
},
"JS - Init loop gate (Baseline)": {
"class": "JavaScriptTransform",
"config": {
"code": "function transform(message, context) {\n // Precompute loop gate from filtered persons array\n message.header.proceedLoop = Array.isArray(message.payload) && message.payload.length > 0;\n return message;\n}",
"pushUndefined": true,
"stopOnError": false
},
"description": "Precompute loop gate before forEach based on filtered persons array length.",
"if": "context.extractBaseline"
},
"GET - names for person (Baseline)": {
"class": "ethosProxyGetFilter",
"config": {
"acceptVersions": [
"12.0.0"
],
"cache": false,
"errorPath": "namesError",
"ignoreErrors": true,
"payloadTargetPath": "namesResponse",
"queryByPost": false,
"resource": "names?criteria={\"personId\":\"{{{message.payload.bannerId}}}\"}"
},
"description": "Retrieve all name records for current person to apply best-record selection logic.",
"if": "context.extractBaseline,message.header.proceedLoop"
},
"GET - emails for person (Baseline)": {
"class": "ethosProxyGetFilter",
"config": {
"acceptVersions": [
"12.0.0"
],
"cache": false,
"errorPath": "emailsError",
"ignoreErrors": true,
"payloadTargetPath": "emailsResponse",
"queryByPost": false,
"resource": "emails?criteria={\"personId\":\"{{{message.payload.bannerId}}}\"}"
},
"description": "Retrieve all email records for current person to apply best-record selection (primary > lowest priority > any available).",
"if": "context.extractBaseline,message.header.proceedLoop"
},
"GET - phones for person (Baseline)": {
"class": "ethosProxyGetFilter",
"config": {
"acceptVersions": [
"12.0.0"
],
"cache": false,
"errorPath": "phonesError",
"ignoreErrors": true,
"payloadTargetPath": "phonesResponse",
"queryByPost": false,
"resource": "phones?criteria={\"personId\":\"{{{message.payload.bannerId}}}\"}"
},
"description": "Retrieve all phone records for current person to apply best-record selection (primary > lowest priority > any available).",
"if": "context.extractBaseline,message.header.proceedLoop"
},
"GET - addresses for person (Baseline)": {
"class": "ethosProxyGetFilter",
"config": {
"acceptVersions": [
"12.0.0"
],
"cache": false,
"errorPath": "addressesError",
"ignoreErrors": true,
"payloadTargetPath": "addressesResponse",
"queryByPost": false,
"resource": "addresses?criteria={\"personId\":\"{{{message.payload.bannerId}}}\"}"
},
"description": "Retrieve all address records for current person to apply best-record selection (active mailing > active permanent > highest-priority active > any available).",
"if": "context.extractBaseline,message.header.proceedLoop"
},
"GET - emergency-contacts for person (Baseline)": {
"class": "ethosProxyGetFilter",
"config": {
"acceptVersions": [
"12.0.0"
],
"cache": false,
"errorPath": "emergencyContactsError",
"ignoreErrors": true,
"payloadTargetPath": "emergencyContactsResponse",
"queryByPost": false,
"resource": "emergency-contacts?criteria={\"personId\":\"{{{message.payload.bannerId}}}\"}"
},
"description": "Retrieve all emergency contact records for current person to apply best-record selection (primary > lowest priority > any available).",
"if": "context.extractBaseline,message.header.proceedLoop"
},
"JS - Apply best-record selection rules (Baseline)": {
"class": "JavaScriptTransform",
"config": {
"code": "function transform(message, context) {\n // Build output row for current person with best-record selections\n const person = message.payload;\n const names = message.payload?.namesResponse || [];\n const emails = message.payload?.emailsResponse || [];\n const phones = message.payload?.phonesResponse || [];\n const addresses = message.payload?.addressesResponse || [];\n const emergencyContacts = message.payload?.emergencyContactsResponse || [];\n\n // Select best name: preferred > legal > any\n const selectName = () => {\n const preferred = names.find(n => n.type === 'preferred' || n.nameType === 'preferred');\n if (preferred) return { first: preferred.firstName || '', last: preferred.lastName || '' };\n const legal = names.find(n => n.type === 'legal' || n.nameType === 'legal' || n.type === 'official');\n if (legal) return { first: legal.firstName || '', last: legal.lastName || '' };\n if (names.length > 0) return { first: names[0].firstName || '', last: names[0].lastName || '' };\n return { first: '', last: '' };\n };\n\n // Select best email: primary > lowest priority > any\n const selectEmail = () => {\n const primaries = emails.filter(e => e.isPrimary === true);\n if (primaries.length === 1) return primaries[0].address || '';\n if (primaries.length > 1) {\n const sorted = primaries.sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999));\n return sorted[0].address || '';\n }\n if (emails.length > 0) {\n const sorted = emails.sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999));\n return sorted[0].address || '';\n }\n return '';\n };\n\n // Select best phone: primary > lowest priority > any\n const selectPhone = () => {\n const primaries = phones.filter(p => p.isPrimary === true);\n if (primaries.length === 1) return primaries[0].number || '';\n if (primaries.length > 1) {\n const sorted = primaries.sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999));\n return sorted[0].number || '';\n }\n if (phones.length > 0) {\n const sorted = phones.sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999));\n return sorted[0].number || '';\n }\n return '';\n };\n\n // Select best address: active mailing > active permanent > highest-priority active > any\n const selectAddress = () => {\n const activeMailing = addresses.find(a => \n (a.type === 'mailing' || a.addressType === 'mailing') && \n (a.isActive === true || a.isCurrent === true)\n );\n if (activeMailing) {\n const streets = [activeMailing.addressLine1, activeMailing.addressLine2, activeMailing.addressLine3]\n .filter(l => l).join(' ');\n return { city: activeMailing.city || '', address: streets };\n }\n const activePermanent = addresses.find(a => \n (a.type === 'permanent' || a.addressType === 'permanent') && \n (a.isActive === true || a.isCurrent === true)\n );\n if (activePermanent) {\n const streets = [activePermanent.addressLine1, activePermanent.addressLine2, activePermanent.addressLine3]\n .filter(l => l).join(' ');\n return { city: activePermanent.city || '', address: streets };\n }\n const activeAddrs = addresses.filter(a => a.isActive === true || a.isCurrent === true);\n if (activeAddrs.length > 0) {\n const sorted = activeAddrs.sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999));\n const addr = sorted[0];\n const streets = [addr.addressLine1, addr.addressLine2, addr.addressLine3]\n .filter(l => l).join(' ');\n return { city: addr.city || '', address: streets };\n }\n if (addresses.length > 0) {\n const addr = addresses[0];\n const streets = [addr.addressLine1, addr.addressLine2, addr.addressLine3]\n .filter(l => l).join(' ');\n return { city: addr.city || '', address: streets };\n }\n return { city: '', address: '' };\n };\n\n // Select best emergency contact: primary > lowest priority > any\n const selectEmergencyContact = () => {\n const primaries = emergencyContacts.filter(ec => ec.isPrimary === true);\n const selectOne = (arr) => {\n if (arr.length === 0) return null;\n if (arr.length === 1) return arr[0];\n const sorted = arr.sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999));\n return sorted[0];\n };\n const selected = selectOne(primaries) || selectOne(emergencyContacts);\n if (!selected) return '';\n const name = selected.name || selected.contactName || '';\n const phone = selected.phone || selected.contactPhone || '';\n if (name && phone) return `${name} - ${phone}`;\n if (name) return name;\n if (phone) return phone;\n return '';\n };\n\n const nameData = selectName();\n const emailData = selectEmail();\n const phoneData = selectPhone();\n const addressData = selectAddress();\n const emergencyContactData = selectEmergencyContact();\n\n // Build CSV row\n message.payload = {\n bannerid: person.bannerId,\n firstName: nameData.first,\n lastName: nameData.last,\n dateofbirth: person.dateOfBirth,\n email: emailData,\n phone: phoneData,\n city: addressData.city,\n address: addressData.address,\n emergencyContact: emergencyContactData\n };\n\n return message;\n}",
"pushUndefined": true,
"stopOnError": false
},
"description": "Apply best-record selection logic for names, emails, phones, addresses, and emergency contacts. Build CSV output row with selected values.",
"if": "context.extractBaseline,message.header.proceedLoop"
},
"Reducer - accumulate CSV rows (Baseline)": {
"class": "reducer",
"config": {
"accumulator": "payload"
},
"description": "Accumulate individual CSV row objects back into a single array for CSV file assembly.",
"if": "context.extractBaseline,message.header.proceedLoop"
},
"JS - No data extract handling (Baseline)": {
"class": "JavaScriptTransform",
"description": "Create output for extract phase when no data",
"if": "context.extractBaseline",
"config": {
"pushUndefined": true,
"stopOnError": false,
"draft": false,
"code": "function transform (message, context) {\n \n if (!Array.isArray(message.payload) || message.payload?.length === 0){\n //throw new Error(`Reading operation returned no data`);\n context.set('doCustomAudit', true);\n context.set('customAuditContent', 'Reading operation returned no data')\n message.payload = [];\n } else if (message.payload.length > 0){\n context.set(\"existDataProcess\", true);\n }\n \n return message;\n}\n"
}
},
"Sub-pipeline - Extract data (Custom/Extend)": {
"class": "SubPipeline",
"description": "Extract data from subpipeline",
"if": "context.extractCustom",
"config": {
"isExtensible": true
}
},
"JS - Format data series (Baseline)": {
"class": "JavaScriptTransform",
"config": {
"code": "function transform(message, context) {\n const rows = Array.isArray(message.payload) ? message.payload : [];\n \n // Build CSV header\n const header = 'bannerid,firstName,lastName,dateofbirth,email,phone,city,address,emergencyContact';\n \n // Escape and quote CSV fields\n const escapeCsvField = (value) => {\n if (value === null || value === undefined) return '';\n const str = String(value);\n if (str.includes(',') || str.includes('\"') || str.includes('\\n')) {\n return `\"${str.replace(/\"/g, '\"\"')}\"`;\n }\n return str;\n };\n \n // Build data rows\n const csvLines = [header];\n rows.forEach(row => {\n const line = [\n escapeCsvField(row.bannerid),\n escapeCsvField(row.firstName),\n escapeCsvField(row.lastName),\n escapeCsvField(row.dateofbirth),\n escapeCsvField(row.email),\n escapeCsvField(row.phone),\n escapeCsvField(row.city),\n escapeCsvField(row.address),\n escapeCsvField(row.emergencyContact)\n ].join(',');\n csvLines.push(line);\n });\n \n // Join with CRLF\n const csvContent = csvLines.join('\\r\\n');\n \n // Store CSV in payload and logs in context\n message.payload = {\n reportLog: [{\n output_csv: csvContent,\n record_count: rows.length,\n batch_id: context.batchId,\n execution_mode: context.executionMode\n }],\n auditLog: [{\n activity: 'CSV File Generation',\n message: `CSV file generated with ${rows.length} data rows (plus 1 header row).`,\n timestamp: new Date().toISOString(),\n batch_id: context.batchId\n }],\n errorLog: []\n };\n \n context.set('csvContent', csvContent);\n context.set('outputRecordCount', rows.length);\n context.set('existProcess', rows.length > 0);\n \n return message;\n}",
"pushUndefined": true,
"stopOnError": false
},
"description": "Format accumulated CSV rows with header and proper quoting. Assemble final CSV content for output."
},
"JS - No data process handling (Baseline)": {
"class": "JavaScriptTransform",
"description": "Create output for process phase when no data",
"if": "context.processBaseline,context.doCustomAudit",
"config": {
"pushUndefined": true,
"stopOnError": false,
"draft": false,
"code": "function transform (message, context) {\n context.set('existProcess', true);\n \n message.payload = {\n reportLog: [],\n auditLog: [],\n errorLog: []\n }\n \n return message;\n}"
}
},
"Sub-pipeline - Process data (Custom/Extend)": {
"class": "SubPipeline",
"description": "Transform extracted data for report file",
"if": "context.processCustom",
"config": {
"isExtensible": true
}
},
"JS - Persist header for report file (Baseline)": {
"class": "JavaScriptTransform",
"description": "Persist payload in header",
"if": "context.existProcess",
"config": {
"pushUndefined": true,
"stopOnError": false,
"code": "function transform (message, context) {\n message.header.fileLogs = message.payload;\n return message;\n}"
}
},
"Sub-pipeline - Transform Report File (Custom)": {
"class": "SubPipeline",
"description": "Transform report file data",
"if": "context.transformCustom",
"config": {
"isExtensible": true
}
},
"JS - Create data for file (Baseline)": {
"class": "JavaScriptTransform",
"config": {
"code": "function transform(message, context) {\n // Prepare final payload for S3 upload (CSV content at root)\n message.payload = context.csvContent || '';\n return message;\n}",
"pushUndefined": true,
"stopOnError": false
},
"description": "Prepare final payload for S3 upload by placing CSV content at root of message.payload."
},
"JS - Add report file content (Baseline)": {
"class": "JavaScriptTransform",
"config": {
"code": "function transform(message, context) {\n // Placeholder for report file content transformation (handled in Send Report File sub-pipeline)\n return message;\n}",
"pushUndefined": true,
"stopOnError": false
},
"description": "Placeholder for report file content processing."
},
"Sub-pipeline - Send Report file (Baseline)": {
"class": "SubPipeline",
"description": "Send Report file",
"if": "context.existReport",
"config": {
"name": "Ellucian-Report-File-Send-Sub",
"isExtensible": false
}
},
"JS - Persist header for Audit file (Baseline)": {
"class": "JavaScriptTransform",
"description": "Persist header in payload",
"if": "context.existProcess",
"config": {
"pushUndefined": true,
"stopOnError": false,
"code": "function transform (message, context) {\n message.payload = message.header.fileLogs;\n return message;\n}"
}
},
"Sub-pipeline - Transform Audit File (Custom)": {
"class": "SubPipeline",
"description": "Transform audit file data",
"if": "context.auditCustom",
"config": {
"isExtensible": true
}
},
"JS - Create Audit file content (Baseline)": {
"class": "JavaScriptTransform",
"description": "Create content for audit file",
"if": "context.auditBaseline",
"config": {
"pushUndefined": true,
"stopOnError": false,
"code": "function transform(message, context) {\n const reportLog = message.payload?.reportLog || [];\n const auditLog = message.payload?.auditLog || [];\n const errorLog = message.payload?.errorLog || [];\n const totalErrors = errorLog?.length;\n const totalProcessed = reportLog?.length;\n const totalRecords = auditLog?.length\n \n if(auditLog?.length > 0 && context.sendAuditFile){\n context.set(\"existAudit\", true);\n \n const universityName = context.universityName || \"Unknown University\";\n const projectName = context.get(\"__pipelineName\") || \"Project Name\";\n const todayDate = getFormattedDate('YYYYMMDD_HHmmss')\n const pipelineParameters = Array.isArray(context.parameters) ? context.parameters : [];\n const data = auditLog;\n\n // Format the pipeline parameters safely\n const parameterContent = pipelineParameters?.[0] ? \n Object.entries(pipelineParameters[0]).map(([key, value]) => `${key} : ${value}`).join('\\n') : \n \"\";\n \n const pad = (str, length) => (str || \"\")?.toString()?.padEnd(length, \" \");\n let headers = [];\n const columnWidths = {};\n headers = Object.keys(data[0]);\n const readableHeaders = headers.map((header) => toReadableHeader(header));\n\n // Initialize column widths with header lengths\n readableHeaders.forEach((header, index) => {\n columnWidths[headers[index]] = header.length + 2;\n });\n\n data?.forEach((log) => {\n headers.forEach((header) => {\n const valueLength = log[header] ? String(log[header]).length : 0;\n columnWidths[header] = Math.max(\n columnWidths[header],\n valueLength + 2\n );\n });\n });\n\n // Create header row\n const headerRow = readableHeaders\n .map((header, index) => pad(header, columnWidths[headers[index]]))\n .join(\"\");\n\n // Create dynamic row generator\n const createRow = (log) => {\n return headers\n .map((key) => pad(log[key], columnWidths[key]))\n .join(\"\");\n };\n \n const auditLogContent = `${getFormattedDate()} ${universityName}\n ${projectName} \n\nPipeline Parameters\n${parameterContent}\n\n${headerRow}\n${data?.map((log) => createRow(log)).join(\"\\n\")}\n\nTotal Records Processed : ${totalRecords}\nTotal Records Successful: ${totalProcessed || 0}\nTotal Records Failed: ${totalErrors || 0}`;\n\n const path = context.auditFilePath.split('/');\n message.payload = {};\n message.payload.sendFileName = `${path[path.length-1]}_${todayDate}.txt`;\n message.payload.fileContent = auditLogContent;\n }\n return message;\n}\n\nfunction getFormattedDate(format = 'MM-DD-YYYY HH:mm:ss') {\n const currentDate = new Date();\n const year = currentDate.getFullYear();\n const month = (currentDate.getMonth() + 1).toString().padStart(2, '0');\n const day = currentDate.getDate().toString().padStart(2, '0');\n const hours = currentDate.getHours().toString().padStart(2, '0');\n const minutes = currentDate.getMinutes().toString().padStart(2, '0');\n const seconds = currentDate.getSeconds().toString().padStart(2, '0');\n\n if (format === 'YYYYMMDD_HHmmss') {\n return `${year}${month}${day}_${hours}${minutes}${seconds}`;\n }\n\n return `${month}-${day}-${year} ${hours}:${minutes}:${seconds}`;\n}\n\nfunction toReadableHeader(key) {\n return key\n .replace(/_/g, ' ')\n .replace(/([a-z])([A-Z])/g, '$1 $2')\n .replace(/\\b\\w/g, char => char.toUpperCase());\n}\n"
}
},
"JS - No data audit handling (Baseline)": {
"class": "JavaScriptTransform",
"description": "Create output for audit phase when no data",
"if": "context.doCustomAudit",
"config": {
"pushUndefined": true,
"stopOnError": false,
"code": "function transform (message, context) {\n const path = context.get(\"auditFilePath\").split('/');\n context.set(\"existAudit\",true);\n message.payload = {}\n message.payload.sendFileName =`${path[path.length-1]}_${getFormattedDateTime()}.txt`;\n message.payload.fileContent = context.customAuditContent;\n \n return message;\n}\n\nfunction getFormattedDateTime() {\n const now = new Date();\n\n const pad = (num) => String(num).padStart(2, '0');\n\n const year = now.getFullYear();\n const month = pad(now.getMonth() + 1);\n const day = pad(now.getDate());\n const hours = pad(now.getHours());\n const minutes = pad(now.getMinutes());\n const seconds = pad(now.getSeconds());\n\n return `${year}${month}${day}_${hours}${minutes}${seconds}`;\n}"
}
},
"Sub-pipeline - Send Audit file (Baseline)": {
"class": "SubPipeline",
"description": "Send Audit file to S3/SFTP",
"if": "context.existAudit,context.sendAuditFile",
"config": {
"name": "Ellucian-Audit-File-Send-Sub",
"isExtensible": false
}
},
"JS - Persist header for Error file (Baseline)": {
"class": "JavaScriptTransform",
"description": "Persist header in payload",
"if": "context.existAudit",
"config": {
"pushUndefined": true,
"stopOnError": false,
"code": "function transform (message, context) {\n message.payload = message.header.fileLogs;\n return message;\n}"
}
},
"Sub-pipeline - Transform Error file (Custom)": {
"class": "SubPipeline",
"description": "Transform Error File data",
"if": "context.errorCustom",
"config": {
"isExtensible": true
}
},
"JS - Create error file content (Baseline)": {
"class": "JavaScriptTransform",
"description": "Create content for Error file",
"if": "context.errorBaseline",
"config": {
"pushUndefined": true,
"stopOnError": false,
"code": "function transform(message, context) {\n const errorLog = message.payload?.errorLog || [];\n \n if(errorLog?.length > 0 && context.sendErrorFile){\n const totalErrors = errorLog.length;\n \n context.set(\"errorCount\", totalErrors);\n context.set(\"existError\", true);\n context.set(\"pipelineError\", true);\n \n const universityName = context.universityName || \"Unknown University\";\n const projectName = context.get(\"__pipelineName\") || \"Project Name\";\n const todayDate = getFormattedDate('YYYYMMDD_HHmmss')\n const data = errorLog;\n\n const pad = (str, length) => (str || \"\")?.toString()?.padEnd(length, \" \");\n \n let headers = [];\n const columnWidths = {};\n headers = Object.keys(data[0]);\n const readableHeaders = headers.map((header) => toReadableHeader(header));\n \n // Initialize column widths with header lengths\n readableHeaders.forEach((header, index) => {\n columnWidths[headers[index]] = header.length + 2;\n });\n \n data?.forEach((log) => {\n headers.forEach((header) => {\n const valueLength = log[header] ? String(log[header]).length : 0;\n columnWidths[header] = Math.max(\n columnWidths[header],\n valueLength + 2\n );\n });\n });\n\n // Create header row\n const headerRow = readableHeaders\n .map((header, index) => pad(header, columnWidths[headers[index]]))\n .join(\"\");\n\n // Create dynamic row generator\n const createRow = (log) => {\n return headers\n .map((key) => pad(log[key], columnWidths[key]))\n .join(\"\");\n };\n \n const errorLogContent = `${getFormattedDate()} ${universityName} \n ${projectName} \n\n\n${headerRow}\n${data?.map((log) => createRow(log)).join(\"\\n\")}\n\nTotal Records Failed: ${totalErrors}`;\n \n const path = context.errorFilePath.split('/');\n message.payload = {};\n message.payload.sendFileName = `${path[path.length-1]}_${todayDate}.txt`;\n message.payload.fileContent = errorLogContent;\n }\n return message;\n}\n\nfunction getFormattedDate(format = 'MM-DD-YYYY HH:mm:ss') {\n const currentDate = new Date();\n const year = currentDate.getFullYear();\n const month = (currentDate.getMonth() + 1).toString().padStart(2, '0');\n const day = currentDate.getDate().toString().padStart(2, '0');\n const hours = currentDate.getHours().toString().padStart(2, '0');\n const minutes = currentDate.getMinutes().toString().padStart(2, '0');\n const seconds = currentDate.getSeconds().toString().padStart(2, '0');\n\n if (format === 'YYYYMMDD_HHmmss') {\n return `${year}${month}${day}_${hours}${minutes}${seconds}`;\n }\n\n return `${month}-${day}-${year} ${hours}:${minutes}:${seconds}`;\n}\n\nfunction toReadableHeader(key) {\n return key\n .replace(/_/g, ' ')\n .replace(/([a-z])([A-Z])/g, '$1 $2')\n .replace(/\\b\\w/g, char => char.toUpperCase());\n}"
}
},
"Sub-pipeline - Send Error file (Baseline)": {
"class": "SubPipeline",
"description": "Send error File file to S3/SFTP",
"if": "context.existError,context.sendErrorFile",
"config": {
"name": "Ellucian-Error-File-Send-Sub",
"isExtensible": false
}
},
"Sub-pipeline - Additional Custom Operations (Custom)": {
"class": "SubPipeline",
"description": "To do additional custom process",
"if": "context.additionalCustomOperations",
"config": {
"isExtensible": true
}
},
"JS - Change pipeline execution status (Baseline)": {
"class": "JavaScriptTransform",
"description": "Change status when error occurs",
"config": {
"pushUndefined": true,
"stopOnError": false,
"code": "function transform (message, context) {\n if(context.pipelineError) {\n throw new Error(\"Pipeline has been failed with errors, please review the error file for more details\");\n }\n return message;\n}"
}
}
}
}
Demo University test-env
Live pipeline performance and job history
Student Contact Export v0.0.1 is live · Built-in Akaden dashboard · Simulated monitoring data
Failures this week
Job runs Today
Succeeded · 1 minute ago · Run #DEMO-002
No runs with this status today.
From campus communications
Student Contact Export Requirements
Business context
The campus communications team needs a nightly contact file for its outreach platform.
The team needs a clean, reliable contact list with each person's preferred name and primary contact information when available.
The outreach platform receives the export file from Amazon S3, used here as a secure handoff location.
Source system
Ellucian Banner SaaS is the source system for this integration.
The integration must extract person/student contact data from Banner through supported Ellucian APIs. It must not rely on direct database access or direct Banner table reads.
This is an outbound data extract integration. The integration must not update Banner data.
Technical objective
Build an outbound integration that:
- Selects Banner person/student records where
dateOfBirthexists and is earlier than2000-01-01. - Applies best-record rules for name, email, phone, address, and emergency contact.
- Generates
output.csvwith the required columns and formatting. - Uploads
output.csvto the configured Amazon S3 location.
Required source data
The integration needs access to the following Banner data:
- Banner ID.
- Date of birth.
- Person names, including preferred and legal/official names.
- Email addresses, including primary indicator and priority when available.
- Phone numbers, including primary indicator and priority when available.
- Addresses, including address type, active/current status, and priority when available.
- Emergency contacts, including primary indicator and phone number when available.
Student/person filter
Include only records where:
dateOfBirthexists;dateOfBirth < 2000-01-01.
Records without dateOfBirth must be excluded from the output file.
Output file requirements
File name:
output.csv
The file must include a header row.
Columns must appear in this exact order:
banneridfirstNamelastNamedateofbirthemailphonecityaddressemergencyContact
Output formatting
Formatting requirements:
dateofbirthmust useYYYY-MM-DDformat.- Missing values must be empty.
- Missing values must not remove, reorder, or shift columns.
- Each selected Banner person/student record should produce one output row.
- The output file must be CSV-compatible for downstream vendor processing.
Best-record rules
Banner may contain multiple names, email addresses, phone numbers, addresses, and emergency contacts for the same person.
The output must contain only one firstName, one lastName, one email, one phone, one city, one address, and one emergencyContact per selected person.
The integration must apply the following best-record rules.
Name selection
Pick the name in this order:
- Preferred name.
- Legal/official name.
- Any available name as fallback.
Use the selected name record for:
firstName;lastName.
If no name is available, leave firstName and lastName blank.
Email selection
Pick email in this order:
- Primary email.
- If multiple primary emails exist, choose the highest-priority email.
- If no primary email exists, choose the highest-priority available email.
- If no email is available, leave
emailblank.
When priority is numeric, the lowest priority number should be treated as the highest priority.
Phone selection
Pick phone in this order:
- Primary phone.
- If multiple primary phones exist, choose the highest-priority phone.
- If no primary phone exists, choose the highest-priority available phone.
- If no phone is available, leave
phoneblank.
When priority is numeric, the lowest priority number should be treated as the highest priority.
Address selection
Pick address in this order:
- Active/current mailing address.
- Active/current permanent address.
- Highest-priority active/current address.
- Any available address as fallback.
Use the selected address record for:
city;address.
The address column should contain the selected street address lines formatted as a single value. If multiple street lines exist, join them with a single space.
If no address is available, leave city and address blank.
Emergency contact selection
Pick emergency contact in this order:
- Primary emergency contact.
- If multiple primary emergency contacts exist, choose the highest-priority emergency contact.
- If no primary emergency contact exists, choose the highest-priority available emergency contact.
- If no emergency contact is available, leave
emergencyContactblank.
The emergencyContact column should be formatted as:
Name - Phone
If the emergency contact has a name but no phone, output only the name.
If the emergency contact has a phone but no name, output only the phone.
S3 delivery
Upload output.csv to Amazon S3.
Default delivery location:
- Bucket:
bucket-name - Key/path:
student_contacts/output.csv
The S3 bucket and key/path should be runtime-configurable.
Runtime parameters
The integration should support the following runtime parameters:
- S3 bucket.
- S3 key/path.
- Date-of-birth cutoff date, defaulting to
2000-01-01. - Optional execution mode, such as
validateOnlyorexport. - Optional batch identifier for traceability.
Error handling and logging
The integration should not fail the full run because an optional contact value is missing.
Missing optional values should be written as empty fields in output.csv.
The integration should fail or report a run-level error when:
- Banner data cannot be queried;
- output CSV generation fails;
- S3 upload fails;
- required runtime parameters are missing;
- the S3 bucket or key/path is invalid.
The integration should produce enough operational logging to confirm:
- number of Banner records evaluated;
- number of records included in
output.csv; - number of records excluded because
dateOfBirthis missing; - number of records excluded because
dateOfBirthis not earlier than the cutoff date; - S3 bucket and key/path used for delivery;
- final export status.
Acceptance criteria
The integration is considered complete when:
- it selects only records where
dateOfBirthexists and is earlier than the configured cutoff date; - it applies best-record rules for name, email, phone, address, and emergency contact;
- it generates
output.csvwith the exact required columns in the exact required order; - missing optional values are represented as empty CSV fields;
dateofbirthvalues are formatted asYYYY-MM-DD;- the file is uploaded to the configured S3 bucket and key/path;
- operational logs clearly show record counts and delivery status;
- the integration does not update Banner data.
Student Contact Export Specification
Pipeline Name and Description
Pipeline Name: Student Contact Export
Pipeline Type: Outbound
Description: Outbound extraction pipeline that prepares a clean student contact file and uploads it to Amazon S3 for the campus outreach platform.
Business Purpose: Give the campus communications team a clean, reliable contact list with preferred names and primary contact information.
Data Source: Ellucian Banner SaaS via supported Ethos APIs (not direct database access).
Output Destination: Amazon S3 in CSV format.
Pipeline Input Parameters
| Parameter Name | Type | Required | Default Value | Description | Validation Rules |
|---|---|---|---|---|---|
dateOfBirthCutoff |
date | No | 2000-01-01 |
Records with dateOfBirth earlier than this value are included. | Must be a valid date in YYYY-MM-DD format. |
s3Bucket |
string | Yes | (none) | Target Amazon S3 bucket name. | Non-empty string; must be a valid AWS S3 bucket name accessible by the pipeline runtime. |
s3KeyPath |
string | No | student_contacts/output.csv |
Full S3 object key/path where output.csv will be stored. | Non-empty string ending with output.csv; can include directory prefix. |
executionMode |
string | No | export |
Execution mode: validateOnly (test configuration without exporting) or export (perform full export). |
Must be validateOnly or export. |
batchIdentifier |
string | No | (auto-generated) | Optional batch identifier for traceability and logging. | Any string; auto-generated as timestamp or UUID if not supplied. |
Pipeline Data Sources
Banner Person Records via Ethos API
The pipeline retrieves person records from Ellucian Banner through the Ethos API catalog. The following Banner contact data must be retrieved:
- Person ID (Banner ID)
- Date of birth
- Person names (preferred name, legal/official name, and any other available names)
- Email addresses (with primary indicator and priority sequence)
- Phone numbers (with primary indicator and priority sequence)
- Addresses (with address type, active/current status, and priority sequence)
- Emergency contacts (with primary indicator, phone number, and priority sequence)
Note: The specific Ethos API endpoints for persons, contact information, and emergency contacts must be verified during implementation. The specification assumes coverage of Banner person/student records, names, emails, phones, addresses, and emergency contacts through baseline Ethos APIs or required SPEC APIs (marked as x-xxx-PersonContacts, x-xxx-PersonEmergencyContacts, etc., if baseline coverage does not exist).
Processing Logic with API Field Mapping
Step 1: Retrieve Person Records
Call the Ethos API endpoint for persons to retrieve all person records with:
- Banner ID
- Date of birth
Filter Applied: At retrieval or post-retrieval JavaScript logic, filter to include only records where:
dateOfBirthis not null/missingdateOfBirth < dateOfBirthCutoffparameter value
Output: Filtered set of person records that meet age criteria.
Step 2: Retrieve Person Names for Each Person
For each person record passing the filter, retrieve associated name records from the Banner names API (or equivalent SPEC API).
Name Selection Logic (applied as JavaScript filtering):
For each person, apply the following sequence to select one best name:
- Check for Preferred Name: Look for a name record where
nameType == "preferred"or similar indicator. If found, use thefirstNameandlastNamefields from this record. - Check for Legal/Official Name: If no preferred name exists, look for a name record where
nameType == "legal"ornameType == "official". If found, usefirstNameandlastName. - Fallback to Any Available Name: If neither preferred nor legal name exists, use the first available name record's
firstNameandlastName. - No Name Case: If no name records are returned, set both
firstNameandlastNameto empty string.
Output: Selected firstName and lastName for each person.
Step 3: Retrieve Email Addresses for Each Person
For each person record passing the filter, retrieve associated email address records from the Banner email API (or equivalent SPEC API).
Email Selection Logic (applied as JavaScript filtering):
For each person, apply the following sequence to select one best email:
- Check for Primary Email: Look for an email record where
isPrimary == true.- If exactly one primary email exists, use it.
- If multiple primary emails exist, select the one with the lowest
prioritynumeric value (treating lowest number as highest priority).
- No Primary Email Case: If no primary email exists, select the email record with the lowest
prioritynumeric value among all available emails. - No Email Case: If no email records are returned, set
emailto empty string.
Output: Selected email address for each person, or empty string if none qualify.
Step 4: Retrieve Phone Numbers for Each Person
For each person record passing the filter, retrieve associated phone number records from the Banner phone API (or equivalent SPEC API).
Phone Selection Logic (applied as JavaScript filtering):
For each person, apply the following sequence to select one best phone number:
- Check for Primary Phone: Look for a phone record where
isPrimary == true.- If exactly one primary phone exists, use it.
- If multiple primary phones exist, select the one with the lowest
prioritynumeric value (treating lowest number as highest priority).
- No Primary Phone Case: If no primary phone exists, select the phone record with the lowest
prioritynumeric value among all available phone numbers. - No Phone Case: If no phone records are returned, set
phoneto empty string.
Output: Selected phone number for each person, or empty string if none qualify.
Step 5: Retrieve Addresses for Each Person
For each person record passing the filter, retrieve associated address records from the Banner address API (or equivalent SPEC API), including:
- Street address lines (may include line1, line2, line3, etc.)
- City
- Address type (e.g., "mailing", "permanent")
- Active/current status
- Priority sequence
Address Selection Logic (applied as JavaScript filtering):
For each person, apply the following sequence to select one best address:
- Check for Active/Current Mailing Address: Look for an address record where
addressType == "mailing"(or similar) ANDisActive == true(orisCurrent == true). If found, use this record'scityand street address lines. - Check for Active/Current Permanent Address: If no active mailing address exists, look for an address record where
addressType == "permanent"(or similar) ANDisActive == true. If found, use this record'scityand street address lines. - Check for Highest-Priority Active/Current Address: If neither mailing nor permanent active address exists, select the address record with the lowest
prioritynumeric value among all active/current addresses. - Fallback to Any Available Address: If no active addresses exist, select the first available address record and use its
cityand street address lines. - No Address Case: If no address records are returned, set both
cityandaddressto empty string.
Address Formatting: Join all available street address lines (if multiple exist) with a single space. Place the result in the address column.
Output: Selected city and address (formatted street lines joined by space) for each person, or empty strings if none qualify.
Step 6: Retrieve Emergency Contacts for Each Person
For each person record passing the filter, retrieve associated emergency contact records from the Banner emergency contacts API (or equivalent SPEC API), including:
- Emergency contact name
- Emergency contact phone number
- Primary indicator
- Priority sequence
Emergency Contact Selection Logic (applied as JavaScript filtering):
For each person, apply the following sequence to select one best emergency contact:
- Check for Primary Emergency Contact: Look for an emergency contact record where
isPrimary == true.- If exactly one primary emergency contact exists, use it.
- If multiple primary emergency contacts exist, select the one with the lowest
prioritynumeric value (treating lowest number as highest priority).
- No Primary Emergency Contact Case: If no primary emergency contact exists, select the emergency contact record with the lowest
prioritynumeric value among all available emergency contacts. - No Emergency Contact Case: If no emergency contact records are returned, set
emergencyContactto empty string.
Emergency Contact Formatting: Format the selected emergency contact value as follows:
- If both name and phone exist:
"Name - Phone" - If only name exists:
"Name" - If only phone exists:
"Phone" - If neither exists: empty string
Output: Formatted emergency contact string for each person, or empty string if none qualify.
Step 7: Build Output CSV File
For each person record passing all filters and selection logic, construct one CSV output row with columns in the following exact order:
| Column Name | Source/Calculation | Format | Required |
|---|---|---|---|
bannerid |
Person ID from Step 1 | String (Banner ID value) | Yes (person record required) |
firstName |
Selected from Step 2 | String | No (empty if no name) |
lastName |
Selected from Step 2 | String | No (empty if no name) |
dateofbirth |
Date of birth from Step 1 | YYYY-MM-DD format |
Yes (applied as filter) |
email |
Selected from Step 3 | String (email address) | No (empty if no email) |
phone |
Selected from Step 4 | String (phone number) | No (empty if no phone) |
city |
Selected from Step 5 | String | No (empty if no address) |
address |
Selected from Step 5 (street lines joined) | String (lines joined by single space) | No (empty if no address) |
emergencyContact |
Selected and formatted from Step 6 | String (Name - Phone or Name or Phone) |
No (empty if none) |
CSV Format Requirements:
- Include one header row with column names as listed above.
- Use comma (
,) as field separator. - Quote fields containing commas, quotes, or newlines as per RFC 4180.
- Use CRLF (
\r\n) as line terminator. - Missing/empty values must be represented as empty fields (no placeholder text).
- One row per selected person record; no duplicate rows.
Logging During Build: Track and log:
- Total number of person records evaluated from initial retrieval.
- Number of records included in output (i.e., passed age filter and have at least bannerid and dateofbirth).
- Number of records excluded because
dateOfBirthis missing. - Number of records excluded because
dateOfBirth >= dateOfBirthCutoff.
Step 8: Upload to Amazon S3
Upload the generated output.csv file to the S3 bucket and key/path specified in the pipeline parameters:
- S3 Bucket: Use the
s3Bucketparameter. - S3 Key/Path: Use the
s3KeyPathparameter (default:student_contacts/output.csv).
Upload Validation:
- Verify the S3 bucket exists and is accessible.
- Verify the S3 key/path is a valid object key (typically includes directory prefix and filename).
- Confirm successful upload with AWS S3 API response.
Logging: Log S3 bucket name, S3 key/path, upload timestamp, and final upload status.
Pipeline Control Flow
The pipeline executes in the following sequence:
Validate Parameters: Verify that all required parameters (
s3Bucket) are supplied and in valid format. Verify that optional parameters (if supplied) conform to their validation rules. If validation fails, log error and stop execution.Retrieve Person Records: Call the Banner persons Ethos API to retrieve all person records (or all persons with dateOfBirth populated). Apply JavaScript filter to include only records where
dateOfBirthis not null ANDdateOfBirth < dateOfBirthCutoff. Track the count of all records evaluated and the count of records excluded by each filter condition.For Each Filtered Person Record:
- Retrieve and Select Name: Call names API, apply name selection logic (preferred > legal > any), output selected firstName and lastName (or empty if no name).
- Retrieve and Select Email: Call email API, apply email selection logic, output selected email (or empty if none).
- Retrieve and Select Phone: Call phone API, apply phone selection logic, output selected phone (or empty if none).
- Retrieve and Select Address: Call address API, apply address selection logic, format street lines with space separator, output city and address (or empty if none).
- Retrieve and Select Emergency Contact: Call emergency contacts API, apply selection logic, format as name-phone pair or name-only or phone-only, output formatted value (or empty if none).
Build CSV Output File: Assemble all selected person records into CSV format with header row, exact column order, date formatting as YYYY-MM-DD, and empty fields for missing values. Track count of rows written.
Conditional Execution Based on Mode:
- If
executionMode == "validateOnly": Write CSV to local temp storage or skip S3 upload. Log validation summary (record counts, sample CSV) and exit successfully. - If
executionMode == "export"(default): Proceed to S3 upload.
- If
Upload to S3: Upload
output.csvto the S3 bucket and key/path specified. Verify bucket accessibility and key/path validity. Log S3 upload status.Write Final Log Summary: Log total evaluation count, total included count, breakdown of exclusions by reason, S3 bucket and key/path used, batch identifier (if supplied), execution timestamp, and final execution status (success or failure).
Exit: If all steps succeed, exit with success status. If any critical step fails (Banner API error, S3 error, parameter validation error), exit with failure status and logged error details.
Examples of Output Files and Format
Sample Output CSV
bannerid,firstName,lastName,dateofbirth,email,phone,city,address,emergencyContact
BAN001,John,Smith,1985-06-15,john.smith@campus.edu,555-123-4567,Boston,123 Main Street Apt 4B,Jane Smith - 555-987-6543
BAN002,Mary,Johnson,1990-03-22,mary.j@email.com,,Portland,456 Oak Avenue Apt 201,
BAN003,Robert,Williams,1978-11-08,,555-234-5678,Seattle,789 Pine Road,Robert Williams
BAN004,Sarah,Brown,1995-01-14,sarah.brown@example.com,555-345-6789,Denver,321 Elm Street,Margaret Chen - 555-456-7890
BAN005,Michael,Davis,1982-09-30,michael@contact.org,,Chicago,,
CSV Format Details
- Header Row: Column names separated by commas:
bannerid,firstName,lastName,dateofbirth,email,phone,city,address,emergencyContact - Data Rows: One row per person record, with values separated by commas.
- Date Format:
YYYY-MM-DD(example:1985-06-15) - Empty Fields: Represented as empty (consecutive commas with no text between them). Missing optional fields do not shift or remove columns.
- Field Quoting: Fields containing commas, quotes, or newlines are enclosed in double quotes and internal quotes are escaped.
- Line Terminator: CRLF (
\r\n) line endings.
Example Log Output
[INFO] Execution started at 2026-08-25T23:30:00Z. Batch ID: batch-20260825-export-001
[INFO] Parameters: s3Bucket=student-contact-bucket, s3KeyPath=student_contacts/output.csv, dateOfBirthCutoff=2000-01-01, executionMode=export
[INFO] Person records evaluated: 2,543
[INFO] Records excluded (dateOfBirth missing): 87
[INFO] Records excluded (dateOfBirth >= cutoff): 1,256
[INFO] Records passed filter and eligible for output: 1,200
[INFO] CSV file generated with 1,200 data rows (plus 1 header row).
[INFO] CSV file uploaded to S3. Bucket: student-contact-bucket. Key: student_contacts/output.csv. Size: 285 KB.
[INFO] Upload status: SUCCESS.
[INFO] Execution completed successfully at 2026-08-25T23:35:42Z.
Internal Test Cases
The following test cases cover material branches in the filtering, selection, and output logic:
| Test Case ID | Setup | Action | Expected Result |
|---|---|---|---|
| TC-001 | Person with complete contact data (name, email, phone, address, emergency contact). dateOfBirth = 1985-06-15. | Process person record. | CSV row contains bannerid, firstName=John, lastName=Smith, dateofbirth=1985-06-15, email=john@example.com, phone=555-1234, city=Boston, address=123 Main St, emergencyContact=Jane Smith - 555-9999. |
| TC-002 | Person with dateOfBirth = 2010-06-15 (after cutoff default 2000-01-01). | Attempt to process. | Person excluded from output. Log: "Records excluded (dateOfBirth >= cutoff): 1". |
| TC-003 | Person with dateOfBirth = NULL. | Attempt to process. | Person excluded from output. Log: "Records excluded (dateOfBirth missing): 1". |
| TC-004 | Person with no names in name API response. | Process person record. | CSV row contains firstName=empty, lastName=empty. |
| TC-005 | Person with preferred name available. Also has legal and another name. | Process names. | firstName and lastName selected from preferred name record (legal and other names ignored). |
| TC-006 | Person with no preferred name. Legal name available. | Process names. | firstName and lastName selected from legal name record. |
| TC-007 | Person with no preferred or legal name. Two other names exist. | Process names. | firstName and lastName selected from first available name record. |
| TC-008 | Person with one primary email (isPrimary=true). Two non-primary emails exist. | Process emails. | email set to primary email address. |
| TC-009 | Person with two primary emails (both isPrimary=true). priority values: 1 and 3. | Process emails. | email set to the email record with priority=1 (lowest numeric priority = highest priority). |
| TC-010 | Person with no primary emails. Three available emails with priority: 5, 2, 8. | Process emails. | email set to email with priority=2 (lowest numeric value). |
| TC-011 | Person with no email records. | Process emails. | email set to empty string. |
| TC-012 | Person with one primary phone. Two non-primary phones exist. | Process phones. | phone set to primary phone number. |
| TC-013 | Person with two primary phones (isPrimary=true). priority values: 2 and 4. | Process phones. | phone set to phone record with priority=2 (lowest numeric priority). |
| TC-014 | Person with no primary phones. Two available phones with priority: 1 and 3. | Process phones. | phone set to phone with priority=1. |
| TC-015 | Person with no phone records. | Process phones. | phone set to empty string. |
| TC-016 | Person with active mailing address (addressType=mailing, isActive=true). Also has active permanent and inactive addresses. | Process addresses. | city and address selected from mailing address record. |
| TC-017 | Person with no active mailing address. Active permanent address exists (addressType=permanent, isActive=true). | Process addresses. | city and address selected from permanent address record. |
| TC-018 | Person with no active mailing or permanent address. Three active addresses with priority: 10, 5, 15. All non-specific types. | Process addresses. | city and address selected from address with priority=5. |
| TC-019 | Person with no active addresses. One inactive address exists. | Process addresses. | city and address selected from available (inactive) address. |
| TC-020 | Person address record has multiple street lines: line1="123 Main St", line2="Apt 4B", line3="Suite 100". | Process addresses. | address set to "123 Main St Apt 4B Suite 100" (lines joined by single space). |
| TC-021 | Person with no address records. | Process addresses. | city and address both set to empty string. |
| TC-022 | Person with one primary emergency contact (isPrimary=true). Contact has name="Jane Smith" and phone="555-9999". | Process emergency contacts. | emergencyContact set to "Jane Smith - 555-9999". |
| TC-023 | Person with primary emergency contact having name="John Doe" but no phone number. | Process emergency contacts. | emergencyContact set to "John Doe". |
| TC-024 | Person with primary emergency contact having phone="555-5555" but no name. | Process emergency contacts. | emergencyContact set to "555-5555". |
| TC-025 | Person with no primary emergency contact. Two non-primary contacts with priority: 3 and 1. Priority=1 contact: name="Alice", phone="555-1111". | Process emergency contacts. | emergencyContact set to "Alice - 555-1111" (contact with priority=1 selected). |
| TC-026 | Person with no emergency contact records. | Process emergency contacts. | emergencyContact set to empty string. |
| TC-027 | CSV file generation with 500 eligible person records. | Generate output file. | CSV contains 1 header row + 500 data rows. All rows have correct column order and format. dateofbirth values formatted as YYYY-MM-DD. Empty fields represented correctly. |
| TC-028 | S3 upload with valid bucket and key/path parameters. | Execute upload to S3. | File uploaded successfully. Log shows S3 bucket name, key, upload timestamp, and success status. |
| TC-029 | S3 upload with invalid bucket name. | Execute upload to S3. | Upload fails. Logged error: "S3 bucket not accessible" or similar. Pipeline exits with failure status. |
| TC-030 | executionMode=validateOnly, valid parameters. | Execute pipeline. | CSV file generated to local temp. S3 upload skipped. Validation summary logged. Exit status: success. |
| TC-031 | executionMode=export (default), all data and parameters valid. | Execute pipeline. | CSV generated and uploaded to S3. Log shows record counts, S3 bucket/key, and success. |
| TC-032 | Banner persons API call fails (API error, timeout, or authentication failure). | Execute pipeline step 1. | Logged error: "Banner API error" or similar. Pipeline exits with failure status. Records processed: 0. |
| TC-033 | Custom dateOfBirthCutoff parameter supplied: 1990-01-01. | Execute with parameter. | Only records with dateOfBirth < 1990-01-01 are included. Other records logged as "excluded (dateOfBirth >= cutoff)". |
| TC-034 | batchIdentifier parameter supplied: "manual-batch-123". | Execute pipeline. | Log entries include "Batch ID: manual-batch-123" for traceability. |
Implementation Notes and Assumptions
Banner Ethos API Verification: This specification assumes that Ellucian Banner Ethos APIs provide baseline coverage for persons, names, emails, phones, addresses, and emergency contacts. If baseline Ethos APIs do not cover all required fields, implement SPEC APIs as needed (e.g.,
x-xxx-PersonContacts,x-xxx-PersonEmergencyContacts). The exact API endpoint names and response schemas must be verified during implementation.Priority Numeric Values: The specification assumes that when priority is numeric, lower values represent higher priority (e.g., priority=1 is higher than priority=5). If Banner API returns priority in reverse order (higher number = higher priority), the selection logic must be inverted.
Boolean Indicators: The specification uses assumed field names and values for indicators (e.g.,
isPrimary,isActive,isCurrent). Actual field names and boolean representations must be confirmed from the Banner Ethos API schema during implementation.Address Type Values: Address types such as "mailing", "permanent" are examples. Actual address type codes or values in Banner must be identified during implementation and the selection logic updated accordingly.
No Direct Banner Database Access: This pipeline must not access Banner database tables directly. All Banner data access must flow through supported Ethos APIs.
CSV Output File Name: The output file is always named
output.csv. The directory/bucket path is configurable vias3KeyPath.Empty Field Handling: Missing optional contact values (name, email, phone, address, emergency contact) are represented as empty CSV fields, not null, not "N/A", not "MISSING". This ensures CSV parsers handle missing data correctly.
Execution Mode Behavior: The
validateOnlymode is intended for testing parameter configuration, API connectivity, and CSV schema without committing data to S3. InvalidateOnlymode, the CSV file can be written to local temp storage or skipped entirely; the log should indicate no S3 upload occurred.Error Resilience: The pipeline does not fail the entire run due to missing optional contact fields for a single person. However, it does fail if Banner API queries cannot complete, CSV generation fails, S3 upload fails, required parameters are missing or invalid, or the S3 bucket/path is not accessible.
Logging and Traceability: All execution must be logged with sufficient detail to troubleshoot, verify data quality, and trace batch identifiers. Logs must include start/end timestamp, all parameters used, record counts by category, S3 details, and final status.
Acceptance Criteria Checklist
- Pipeline selects only records where
dateOfBirthexists ANDdateOfBirth < dateOfBirthCutoff(default 2000-01-01). - Best-record rules applied correctly for name, email, phone, address, and emergency contact (see selection logic in Processing Logic section).
- Output CSV includes exact columns in exact order:
bannerid,firstName,lastName,dateofbirth,email,phone,city,address,emergencyContact. - CSV header row present. Data rows follow with one row per selected person.
- Missing optional values represented as empty CSV fields (no placeholder text or null).
-
dateofbirthformatted asYYYY-MM-DD. - Street address lines joined with single space.
- Emergency contact formatted as "Name - Phone" or "Name" or "Phone" or empty.
- File uploaded to configured S3 bucket and key/path.
- Operational logs show record counts (evaluated, included, excluded-by-reason), S3 details, batch identifier, and final status.
- Pipeline does not update Banner data.
- All Banner data access through Ethos APIs (not direct database).
- Required parameters validated before execution. Invalid parameters cause exit with error log.
- Optional parameters used if supplied; defaults applied if not supplied.
- Parameter:
dateOfBirthCutoff(date, optional, default 2000-01-01). - Parameter:
s3Bucket(string, required). - Parameter:
s3KeyPath(string, optional, defaultstudent_contacts/output.csv). - Parameter:
executionMode(string, optional, defaultexport; valid values:validateOnlyorexport). - Parameter:
batchIdentifier(string, optional, auto-generated if not supplied). - All test cases pass (TC-001 through TC-034).
Student Contact Export Pipeline
Pipeline File: student-contact-export.pipeline
Template: v3.1Outbound
Status: Ready for Testing
Overview
The Student Contact Export pipeline is an Akaden-generated outbound extraction service that:
- Retrieves and filters Banner person/student records by age criteria (dateOfBirth < configurable cutoff)
- Enriches each filtered person with contact information using best-record selection rules
- Generates a CSV export file with normalized contact data
- Uploads the CSV to Amazon S3 for the campus outreach platform
Pipeline Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
dateOfBirthCutoff |
string | No | 2000-01-01 |
Records with dateOfBirth earlier than this value (YYYY-MM-DD) are included. |
s3BucketName |
string | Yes | None | Target Amazon S3 bucket name. |
s3KeyPath |
string | No | student_contacts/output.csv |
Full S3 object key/path for the output CSV file. |
executionMode |
enum | No | export |
validateOnly (test config) or export (full export). |
batchIdentifier |
string | No | Auto-generated | Optional batch identifier for traceability. |
Business Logic
Data Extraction (Phase 1)
GET - persons (Baseline)
Retrieves all person records from Banner with dateOfBirth populated.JS - Filter and enrich persons by age (Baseline)
- Filters records where dateOfBirth is not null AND dateOfBirth < dateOfBirthCutoff
- Tracks evaluation counts (evaluated, excluded_missing_dob, excluded_dob_after_cutoff)
- Preserves filtered persons for enrichment loop
For Each - filtered persons (Baseline)
Iterates over each filtered person record.Contact API Calls (Inside Loop)
For each person, retrieves:- Names → applies best-record rule (preferred > legal > any)
- Emails → applies best-record rule (primary > lowest priority > any)
- Phones → applies best-record rule (primary > lowest priority > any)
- Addresses → applies best-record rule (active mailing > active permanent > highest-priority active > any)
- Emergency Contacts → applies best-record rule (primary > lowest priority > any)
JS - Apply best-record selection rules (Baseline)
Constructs CSV row object with selected contact values for each person.Reducer - accumulate CSV rows (Baseline)
Accumulates individual row messages back into a single array.
Data Processing (Phase 2)
JS - Format data series (Baseline)
- Builds CSV with header row:
bannerid,firstName,lastName,dateofbirth,email,phone,city,address,emergencyContact - Applies RFC 4180 CSV escaping (quotes, field delimiters, newlines)
- Uses CRLF line terminators
- Stores final CSV content in context for S3 upload
- Builds CSV with header row:
JS - Create data for file (Baseline)
Places CSV content at root of message.payload for S3 writer.
Output (Phase 3)
- Sub-pipeline - Send Report file (Baseline)
Handles S3 upload based on execution mode:validateOnly: Skips S3 upload, logs validation summaryexport: Uploads CSV to configured S3 bucket and key/path
Output CSV Format
Example CSV
bannerid,firstName,lastName,dateofbirth,email,phone,city,address,emergencyContact
BAN001,John,Smith,1985-06-15,john.smith@campus.edu,555-123-4567,Boston,123 Main Street Apt 4B,Jane Smith - 555-987-6543
BAN002,Mary,Johnson,1990-03-22,mary.j@email.com,,Portland,456 Oak Avenue Apt 201,
BAN003,Robert,Williams,1978-11-08,,555-234-5678,Seattle,789 Pine Road,Robert Williams
Format Rules
- Header Row: One row with column names
- Data Rows: One row per selected person
- Columns (exact order): bannerid, firstName, lastName, dateofbirth, email, phone, city, address, emergencyContact
- Date Format:
YYYY-MM-DD - Empty Fields: Represented as empty (no placeholder text, no null)
- CSV Escaping: RFC 4180 compliant (comma, quote, newline handling)
- Line Terminator: CRLF (
\r\n)
Operational Logs
The pipeline generates execution logs with:
Evaluation Counts:
- Total person records evaluated
- Records excluded (missing DOB)
- Records excluded (DOB >= cutoff)
- Records included in output
S3 Details (export mode):
- Bucket name
- Object key/path
- Upload status
- Timestamp
Execution Metadata:
- Batch identifier
- Execution mode (validateOnly/export)
- Start/end timestamps
- Final status (success/failure)
Key Design Notes
Best-Record Selection Rules
The pipeline applies deterministic selection logic for each contact field:
- Names: Preferred → Legal → Any Available → Empty
- Emails: Primary (single) → Primary (lowest priority) → Any (lowest priority) → Empty
- Phones: Primary (single) → Primary (lowest priority) → Any (lowest priority) → Empty
- Addresses: Active Mailing → Active Permanent → Highest-Priority Active → Any → Empty
- Street lines joined with single space
- Emergency Contacts: Primary (single) → Primary (lowest priority) → Any (lowest priority) → Empty
- Formatted as "Name - Phone" or "Name" or "Phone" or Empty
Loop Architecture
- Outer Loop: Iterates over filtered person records
- Enrichment Calls: Five sequential Ethos API calls per person (names, emails, phones, addresses, emergency contacts)
- Best-Record Logic: Applied in JavaScript transform after all APIs return
- Reduction: Accumulates individual rows back into array before CSV assembly
S3 Integration
- Uses v3.1Outbound template's built-in S3 sub-pipelines
- S3 credentials and connection validation handled by template
- Execution mode controls whether upload occurs
- CSV content prepared before writer for proper format
Error Handling
- Ethos API errors logged but do not block iteration (ignoreErrors: true)
- Missing optional contact fields result in empty CSV fields (not null or placeholder)
- Required parameters validated before execution
- S3 upload failures logged with detail
- Banner API failures cause pipeline exit with error
Validation Checklist
- Pipeline created with v3.1Outbound template
- All required parameters defined
- Age filtering logic implemented with tracking
- Best-record selection rules for all contact fields
- CSV assembly with proper RFC 4180 formatting
- S3 upload integration via template sub-pipeline
- Execution mode (validateOnly/export) branching
- Operational logging for audit trail
- Pipeline structure validated (no errors, 5 warnings on API endpoint names)
Note: The 5 validation warnings are expected because some Ethos API endpoints (names, emails, phones) are not yet cataloged in the baseline. These may be SPEC APIs that need to be created during implementation, or the actual endpoint names may differ. During implementation, verify exact API names and versions from the Banner Ethos API catalog and update the resource definitions accordingly.
Next Steps
API Endpoint Verification
- Confirm actual Ethos API names for person contacts (names, emails, phones, addresses, emergency-contacts)
- Verify API versions match the Banner environment
- Update resource definitions if endpoint names differ
Test Data Preparation
- Create test Banner records with various combinations of contact data
- Verify best-record selection logic with known inputs
- Validate CSV output format and encoding
S3 Connection Setup
- Configure S3 bucket access
- Test pipeline with validateOnly mode first
- Verify S3 upload with export mode
Full Test Suite Execution
- Run all 34 test cases from specification (TC-001 through TC-034)
- Verify record counts and filtering logic
- Validate CSV content and S3 placement
- Test parameter variations (dateOfBirthCutoff, batchIdentifier)
Performance Baseline
- Measure extraction time for various record counts
- Validate S3 upload performance
- Document baseline metrics for monitoring
Troubleshooting
API Endpoint Not Found
Symptom: Validation warnings about names, emails, phones endpoints not in Ethos catalog
Resolution: These may be SPEC APIs. Verify the correct endpoint names in your Banner Ethos API documentation and update resource configurations.
Empty CSV Output
Symptom: CSV generated with header row only, no data rows
Resolution:
- Check that filtered persons array contains records (evaluatedPersonCount > 0)
- Verify API calls are returning contact data
- Check the dateOfBirthCutoff parameter value. It may be filtering all records.
S3 Upload Fails
Symptom: executionMode=export but CSV not appearing in S3
Resolution:
- Verify S3 bucket name and key/path parameters
- Confirm S3 credentials are valid
- Check that S3 bucket allows write access
- Review S3 sub-pipeline logs for specific error
Wrong Contact Values Selected
Symptom: CSV contains non-preferred names, secondary phones, etc.
Resolution:
- Verify Banner contact data has correct type/priority values
- Check that API responses include isPrimary and priority fields
- Review best-record selection logic in JS - Apply best-record selection rules segment
- Test with known sample data to validate assumptions
Pipeline File Location: /pipelines/student-contact-export.pipeline
Specification: /specifications/student-contact-export-spec.md
Last Updated: 2026-08-25
Student Contact Export
39 generated steps from the approved specification
JS - Audit Trail v3.1.0
JS transform
Pipeline details log
JavaScript transform Normalizes the contact record and applies the approved mapping rules.GET - installation-controls (Baseline)
Ethos API
Get university name
GET /installation-controls Filters the required student records through Ethos.JS - Fetch University or School Name (Baseline)
JS transform
Fetch University/School Name
JavaScript transform Normalizes the contact record and applies the approved mapping rules.JS - Baseline validation setup (Baseline)
JS transform
Setup up parameter for extensibility validation
JavaScript transform Normalizes the contact record and applies the approved mapping rules.Sub-pipeline - Extensibility Baseline Validation (Baseline)
Sub-pipeline
Validation baseline and extensibility parameter requirement validation
Calls a reusable sub-pipeline Passes this record into the shared mapping flow.Sub-pipeline - Validate Credentials (Baseline)
Sub-pipeline
Validate SFTP, S3 credentials and Ethos API Key
Calls a reusable sub-pipeline Passes this record into the shared mapping flow.Sub-pipeline - Baseline Parameter User Value Validation (Baseline)
Sub-pipeline
Baseline Parameter Value Validation of user input
Calls a reusable sub-pipeline Passes this record into the shared mapping flow.GET - persons (Baseline)
Ethos API
Fetch all person records from Banner with dateOfBirth populated for age filtering.
GET /persons Filters the required student records through Ethos.JS - Filter and enrich persons by age (Baseline)
JS transform
Filter persons by age criteria (dateOfBirth < cutoff) and track evaluation counts. Payload becomes array of filtered person records with bannerId, dateOfBirth, and original person object.
JavaScript transform Normalizes the contact record and applies the approved mapping rules.For Each - filtered persons (Baseline)
Loop
Iterate over filtered person records to enrich with contact information.
for each record Runs the next steps once for every matching student.JS - Init loop gate (Baseline)
JS transform
Precompute loop gate before forEach based on filtered persons array length.
JavaScript transform Normalizes the contact record and applies the approved mapping rules.GET - names for person (Baseline)
Ethos API
Retrieve all name records for current person to apply best-record selection logic.
GET /names Filters the required student records through Ethos.GET - emails for person (Baseline)
Ethos API
Retrieve all email records for current person to apply best-record selection (primary > lowest priority > any available).
GET /emails Filters the required student records through Ethos.GET - phones for person (Baseline)
Ethos API
Retrieve all phone records for current person to apply best-record selection (primary > lowest priority > any available).
GET /phones Filters the required student records through Ethos.GET - addresses for person (Baseline)
Ethos API
Retrieve all address records for current person to apply best-record selection (active mailing > active permanent > highest-priority active > any available).
GET /addresses Filters the required student records through Ethos.GET - emergency-contacts for person (Baseline)
Ethos API
Retrieve all emergency contact records for current person to apply best-record selection (primary > lowest priority > any available).
GET /emergency-contacts Filters the required student records through Ethos.JS - Apply best-record selection rules (Baseline)
JS transform
Apply best-record selection logic for names, emails, phones, addresses, and emergency contacts. Build CSV output row with selected values.
JavaScript transform Normalizes the contact record and applies the approved mapping rules.Reducer - accumulate CSV rows (Baseline)
Reducer
Accumulate individual CSV row objects back into a single array for CSV file assembly.
Collect output rows Builds the final CSV ready for delivery.JS - No data extract handling (Baseline)
JS transform
Create output for extract phase when no data
JavaScript transform Normalizes the contact record and applies the approved mapping rules.Sub-pipeline - Extract data (Custom/Extend)
Sub-pipeline
Extract data from subpipeline
Calls a reusable sub-pipeline Passes this record into the shared mapping flow.JS - Format data series (Baseline)
JS transform
Format accumulated CSV rows with header and proper quoting. Assemble final CSV content for output.
JavaScript transform Normalizes the contact record and applies the approved mapping rules.JS - No data process handling (Baseline)
JS transform
Create output for process phase when no data
JavaScript transform Normalizes the contact record and applies the approved mapping rules.Sub-pipeline - Process data (Custom/Extend)
Sub-pipeline
Transform extracted data for report file
Calls a reusable sub-pipeline Passes this record into the shared mapping flow.JS - Persist header for report file (Baseline)
JS transform
Persist payload in header
JavaScript transform Normalizes the contact record and applies the approved mapping rules.Sub-pipeline - Transform Report File (Custom)
Sub-pipeline
Transform report file data
Calls a reusable sub-pipeline Passes this record into the shared mapping flow.JS - Create data for file (Baseline)
JS transform
Prepare final payload for S3 upload by placing CSV content at root of message.payload.
JavaScript transform Normalizes the contact record and applies the approved mapping rules.JS - Add report file content (Baseline)
JS transform
Placeholder for report file content processing.
JavaScript transform Normalizes the contact record and applies the approved mapping rules.Sub-pipeline - Send Report file (Baseline)
Sub-pipeline
Send Report file
Calls a reusable sub-pipeline Passes this record into the shared mapping flow.JS - Persist header for Audit file (Baseline)
JS transform
Persist header in payload
JavaScript transform Normalizes the contact record and applies the approved mapping rules.Sub-pipeline - Transform Audit File (Custom)
Sub-pipeline
Transform audit file data
Calls a reusable sub-pipeline Passes this record into the shared mapping flow.JS - Create Audit file content (Baseline)
JS transform
Create content for audit file
JavaScript transform Normalizes the contact record and applies the approved mapping rules.JS - No data audit handling (Baseline)
JS transform
Create output for audit phase when no data
JavaScript transform Normalizes the contact record and applies the approved mapping rules.Sub-pipeline - Send Audit file (Baseline)
Sub-pipeline
Send Audit file to S3/SFTP
Calls a reusable sub-pipeline Passes this record into the shared mapping flow.JS - Persist header for Error file (Baseline)
JS transform
Persist header in payload
JavaScript transform Normalizes the contact record and applies the approved mapping rules.Sub-pipeline - Transform Error file (Custom)
Sub-pipeline
Transform Error File data
Calls a reusable sub-pipeline Passes this record into the shared mapping flow.JS - Create error file content (Baseline)
JS transform
Create content for Error file
JavaScript transform Normalizes the contact record and applies the approved mapping rules.Sub-pipeline - Send Error file (Baseline)
Sub-pipeline
Send error File file to S3/SFTP
Calls a reusable sub-pipeline Passes this record into the shared mapping flow.Sub-pipeline - Additional Custom Operations (Custom)
Sub-pipeline
To do additional custom process
Calls a reusable sub-pipeline Passes this record into the shared mapping flow.JS - Change pipeline execution status (Baseline)
JS transform
Change status when error occurs
JavaScript transform Normalizes the contact record and applies the approved mapping rules.
Configure job
Student Contact Export · Published version 0.0.1 · Demo University
Read from the pipeline. Date of Birth Cutoff is the value your specification set.
Job run #DEMO-001
Simulated run · Published version 0.0.1 · Demo University
[INFO] Execution started. Batch ID: batch-20260825-export-001
[INFO] Parameters: s3Bucket=student-contact-bucket, s3KeyPath=student_contacts/output.csv, dateOfBirthCutoff={{CUTOFF}}, executionMode=export
[INFO] Person records evaluated: 2,543
[INFO] Records excluded (dateOfBirth missing): 87
[INFO] Records excluded (dateOfBirth >= cutoff): 1,256
[INFO] Records passed filter and eligible for output: 1,200
[INFO] CSV file generated with 1,200 data rows (plus 1 header row).
[INFO] CSV uploaded to S3. Key: student_contacts/output.csv. Size: 285 KB.
[INFO] Upload status: SUCCESS.
[INFO] Execution completed successfully.output.csv
| bannerid | firstName | lastName | dateofbirth | phone | city | address | emergencyContact | |
|---|---|---|---|---|---|---|---|---|
| BAN001 | John | Smith | 1985-06-15 | john.smith@example.edu | 555-123-4567 | Boston | 123 Main Street Apt 4B | Jane Smith - 555-987-6543 |
| BAN002 | Mary | Johnson | 1990-03-22 | mary.j@email.com | N/A | Portland | 456 Oak Avenue Apt 201 | N/A |
| BAN003 | Robert | Williams | 1978-11-08 | N/A | 555-234-5678 | Seattle | 789 Pine Road | Robert Williams |
| BAN004 | Sarah | Brown | 1995-01-14 | sarah.brown@example.com | 555-345-6789 | Denver | 321 Elm Street | Margaret Chen - 555-456-7890 |
| BAN005 | Michael | Davis | 1982-09-30 | michael@contact.org | N/A | Chicago | N/A | N/A |
Synthetic records. Nothing on this page resembles real student data.
Acceptance criteria
- Selects only records where dateOfBirth exists and is earlier than the cutoff
- Best-record rules applied for name, email, phone, address, emergency contact
- Output CSV has the exact nine columns in the exact order
- Missing optional values written as empty fields
- dateofbirth formatted as YYYY-MM-DD
- File uploaded to the configured S3 bucket and key/path
- The integration does not update Banner data
Saves a draft in Ellucian. Publish separately to make this version live.