From cea7f7af075abe85b1f40197431100a2f8b3bbe3 Mon Sep 17 00:00:00 2001 From: yesenkaya <142787748+yesenkaya@users.noreply.github.com> Date: Mon, 24 Nov 2025 11:11:03 +0100 Subject: [PATCH 1/6] Update configuration.json Added default value for Baseurl --- configuration.json | 102 ++++++++++++++++++++++----------------------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/configuration.json b/configuration.json index 9c71e5c..e52d49e 100644 --- a/configuration.json +++ b/configuration.json @@ -1,53 +1,53 @@ [ - { - "key": "Username", - "type": "input", - "defaultValue": "", - "templateOptions": { - "label": "Username", - "description": "The username to connect to the API", - "required": true - } - }, - { - "key": "Password", - "type": "input", - "defaultValue": "", - "templateOptions": { - "type": "password", - "label": "ApiKey", - "description": "The password to connect to the API", - "required": true - } - }, - { - "key": "BaseUrl", - "type": "input", - "defaultValue": "", - "templateOptions": { - "label": "BaseUrl", - "description": "The URL to the API.", - "required": true - } - }, - { - "key": "HistoricalDays", - "type": "input", - "defaultValue": "1", - "templateOptions": { - "label": "HistoricalDays", - "description": "The number of days in the past from which the shifts will be imported.", - "required": true - } - }, - { - "key": "FutureDays", - "type": "input", - "defaultValue": "2", - "templateOptions": { - "label": "FutureDays", - "description": "The number of days in the future from which the shifts will be imported.", - "required": true - } + { + "key": "Username", + "type": "input", + "defaultValue": "", + "templateOptions": { + "label": "Username", + "description": "The username to connect to the API", + "required": true } - ] \ No newline at end of file + }, + { + "key": "Password", + "type": "input", + "defaultValue": "", + "templateOptions": { + "type": "password", + "label": "ApiKey", + "description": "The password to connect to the API", + "required": true + } + }, + { + "key": "BaseUrl", + "type": "input", + "defaultValue": "https://[customer].(test).rooster.nl/InPlanningServiceAdmin/rest/api", + "templateOptions": { + "label": "BaseUrl", + "description": "The URL to the API.", + "required": true + } + }, + { + "key": "HistoricalDays", + "type": "input", + "defaultValue": "1", + "templateOptions": { + "label": "HistoricalDays", + "description": "The number of days in the past from which the shifts will be imported.", + "required": true + } + }, + { + "key": "FutureDays", + "type": "input", + "defaultValue": "2", + "templateOptions": { + "label": "FutureDays", + "description": "The number of days in the future from which the shifts will be imported.", + "required": true + } + } +] From f769441079fad677b8cc0794b7d745e3bcb31546 Mon Sep 17 00:00:00 2001 From: yesenkaya <142787748+yesenkaya@users.noreply.github.com> Date: Mon, 24 Nov 2025 11:41:43 +0100 Subject: [PATCH 2/6] Update persons.ps1 Build a delay in foreach Abort after 58 sec if no contracts are retrieved in the foreach Check date formatting Add contracts with or without function name. --- persons.ps1 | 85 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 63 insertions(+), 22 deletions(-) diff --git a/persons.ps1 b/persons.ps1 index 176293e..d1b0a51 100644 --- a/persons.ps1 +++ b/persons.ps1 @@ -1,7 +1,7 @@ ################################################## # HelloID-Conn-Prov-Source-Inplanning-Persons # -# Version: 1.1.0 +# Version: 1.1.1 ################################################## # Initialize default value's $config = $configuration | ConvertFrom-Json @@ -95,7 +95,10 @@ try { $startDate = $today.AddDays( - $($config.HistoricalDays)).ToString('yyyy-MM-dd') $endDate = $today.AddDays($($config.FutureDays)).ToString('yyyy-MM-dd') + + foreach ($person in $persons) { + start-sleep -Milliseconds 500 try { If(($person.resource.Length -gt 0) -Or ($null -ne $person.resource)){ @@ -116,9 +119,17 @@ try { Uri = "$($config.BaseUrl)/roster/resourceRoster?resource=$($person.resource)&startDate=$($startDate)&endDate=$($endDate)" Headers = $headers Method = 'GET' + TimeoutSec = 45 } + + try { + $personShifts = Invoke-RestMethod @splatGetUsersShifts + } catch { + Write-warning "Timeout or error fetching shifts for user [$($person.username)] with resource ID [$($person.resource)]. Error: $($_.Exception.Message)" + continue + } - $personShifts = Invoke-RestMethod @splatGetUsersShifts + #$personShifts = Invoke-RestMethod @splatGetUsersShifts If($personshifts.count -gt 0){ $counter = 0 @@ -130,18 +141,39 @@ try { $rosterDate = $day.rosterDate foreach ($part in $day.parts) { $counter = ($counter + 1) - # Define the pattern for hh:mm-hh:mm - $pattern = '^\d{2}:\d{2}-\d{2}:\d{2}' - $time = [regex]::Match($part.shift.uname, $pattern) - - if ($time.Success) { - $times = $time.value -split '-' - $startTime = $times[0] - $endTime = $times[1] - } else { - $startTime = '00:00' - $endTime = '00:00' - } + if ($part.shift.uname -like '*:*') { + $pattern = '^\d{2}:\d{2}-\d{2}:\d{2}' + $isFormatted = $true + } else { + # Formaat: hhmm-hhmm . + $pattern = '^\d{4}-\d{4}' + $isFormatted = $false + } + + $time = [regex]::Match($part.shift.uname, $pattern) + + if ($time.Success) { + $times = $time.value -split '-' + + $startTimeUnformatted = $times[0] + $endTimeUnformatted = $times[1] + + #Formatteer naar HH:MM + if (-not $isFormatted) { + # Converteert "0700" naar "07:00" + $startTime = "$($startTimeUnformatted.Substring(0, 2)):$($startTimeUnformatted.Substring(2, 2))" + $endTime = "$($endTimeUnformatted.Substring(0, 2)):$($endTimeUnformatted.Substring(2, 2))" + } else { + + $startTime = $startTimeUnformatted + $endTime = $endTimeUnformatted + } + + } else { + $startTime = '00:00' + $endTime = '00:00' + + } if($part.prop){ $functioncode = $part.prop.uname @@ -165,8 +197,14 @@ try { startAt = "$($rosterDate)T$($startTime):00Z" endAt = "$($rosterDate)T$($endTime):00Z" } - - $contracts.Add($ShiftContract) + if ( + ([string]::IsNullOrEmpty($ShiftContract.functionname) -ne $True) # Function should not be empty. + ) { + $contracts.Add($ShiftContract) + } else { + #$contracts.Add($ShiftContract) # If you need the contracts without the functionnames enable this line. + } + } } } @@ -178,7 +216,10 @@ try { FirstName = $person.firstName LastName = $person.lastName Email = $person.email - Contracts = $contracts + Role = $($person.roles.role | Sort-Object | Get-Unique) + resourceGroup= $($person.roles.resourceGroup | Sort-Object | Get-Unique) + shiftGroup = $($person.roles.shiftGroup | Sort-Object | Get-Unique) + Contracts = $contracts } Write-Output $personObj | ConvertTo-Json -Depth 20 }} @@ -186,11 +227,11 @@ try { $ex = $PSItem if ($($ex.Exception.GetType().FullName -eq 'Microsoft.PowerShell.Commands.HttpResponseException')) { $errorObj = Resolve-InplanningError -ErrorObject $ex - Write-Verbose "Could not import Inplanning person [$($person.uname)]. Error at Line '$($errorObj.ScriptLineNumber)': $($errorObj.Line). Error: $($errorObj.ErrorDetails)" - Write-Error "Could not import Inplanning person [$($person.uname)]. Error: $($errorObj.FriendlyMessage)" + Write-Verbose "Could not import Inplanning person [$($person.username)]. Error at Line '$($errorObj.ScriptLineNumber)': $($errorObj.Line). Error: $($errorObj.ErrorDetails)" + Write-Error "Could not import Inplanning person [$($person.username)]. Error: $($errorObj.FriendlyMessage)" } else { - Write-Verbose "Could not import Inplanning person [$($person.uname)]. Error at Line '$($ex.InvocationInfo.ScriptLineNumber)': $($ex.InvocationInfo.Line). Error: $($ex.Exception.Message)" - Write-Error "Could not import Inplanning person [$($person.uname)]. Error: $($errorObj.FriendlyMessage)" + Write-Verbose "Could not import Inplanning person [$($person.username)]. Error at Line '$($ex.InvocationInfo.ScriptLineNumber)': $($ex.InvocationInfo.Line). Error: $($ex.Exception.Message)" + Write-Error "Could not import Inplanning person [$($person.username)]. Error: $($errorObj.FriendlyMessage)" } } } @@ -204,4 +245,4 @@ try { Write-Verbose "Could not import Inplanning persons. Error at Line '$($ex.InvocationInfo.ScriptLineNumber)': $($ex.InvocationInfo.Line). Error: $($ex.Exception.Message)" Write-Error "Could not import Inplanning persons. Error: $($errorObj.FriendlyMessage)" } -} \ No newline at end of file +} From 2ecee20acdace7c30fb252363c3f693df957d0a1 Mon Sep 17 00:00:00 2001 From: yesenkaya <142787748+yesenkaya@users.noreply.github.com> Date: Tue, 23 Dec 2025 12:08:38 +0100 Subject: [PATCH 3/6] Update persons.ps1 --- persons.ps1 | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/persons.ps1 b/persons.ps1 index d1b0a51..cd74308 100644 --- a/persons.ps1 +++ b/persons.ps1 @@ -1,7 +1,7 @@ ################################################## # HelloID-Conn-Prov-Source-Inplanning-Persons # -# Version: 1.1.1 +# Version: 1.1.0 ################################################## # Initialize default value's $config = $configuration | ConvertFrom-Json @@ -119,17 +119,31 @@ try { Uri = "$($config.BaseUrl)/roster/resourceRoster?resource=$($person.resource)&startDate=$($startDate)&endDate=$($endDate)" Headers = $headers Method = 'GET' - TimeoutSec = 45 + TimeoutSec = 3 } - try { + # Retry logic for fetching shifts + $maxRetries = 3 + $retryCount = 0 + $success = $false + + while (-not $success -and $retryCount -lt $maxRetries) { + try { $personShifts = Invoke-RestMethod @splatGetUsersShifts + $success = $true } catch { - Write-warning "Timeout or error fetching shifts for user [$($person.username)] with resource ID [$($person.resource)]. Error: $($_.Exception.Message)" - continue - } - - #$personShifts = Invoke-RestMethod @splatGetUsersShifts + $retryCount++ + if ($retryCount -lt $maxRetries) { + Write-Warning "Retrying shifts for user [$($person.username)] with resource ID [$($person.resource)]... ($retryCount/$maxRetries). Error: $($_.Exception.Message)" + Start-Sleep -Milliseconds 500 + } + } + } + + if (-not $success) { + Write-Warning "Could not fetch shifts for user [$($person.username)] with resource ID [$($person.resource)] after $maxRetries attempts. Skipping user." + continue + } If($personshifts.count -gt 0){ $counter = 0 From 012e3e974ef47b9bfcca857d8bbb85c37bd2ca9f Mon Sep 17 00:00:00 2001 From: yesenkaya <142787748+yesenkaya@users.noreply.github.com> Date: Tue, 23 Dec 2025 12:10:53 +0100 Subject: [PATCH 4/6] Update mapping.json --- mapping.json | 142 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 124 insertions(+), 18 deletions(-) diff --git a/mapping.json b/mapping.json index 969e996..29448fa 100644 --- a/mapping.json +++ b/mapping.json @@ -1,78 +1,184 @@ { "personMappings": [ + { + "name": "Aggregation", + "mode": "complex", + "value": "/* This value is used for automatic person aggregation (if enabled) to suggest aggregation of persons based on similar aggregation values.\r\n* The value will be converted to upper case and all white-spaces and special characters, except single quotes, will be removed.\r\n* This field is limited to 200 characters, empty or null values will exclude the person from suggestions.\r\n*/\r\n\r\nfunction getAggregationValue() {\r\n\tlet value = \"PERSON\" + source.ExternalId + \"PERSON\"\r\n\treturn value;\r\n}\r\n\r\n\r\n\r\n/*\r\nfunction getAggregationValue() {\r\n\tlet value = ''\r\n\tif (source.DisplayName) {\r\n\t\tvalue = value.concat(source.DisplayName.split(\" \")[0]);\r\n\t}\r\n\tif (source.lastName) {\r\n\t\tvalue = value.concat(source.lastName);\r\n\t}\r\n\tif (source.dateOfBirth) {\r\n\t\tlet d = new Date(source.dateOfBirth);\r\n\t\tlet birthDate = d.getFullYear() + '' + d.getMonth() + '' + d.getDate();\r\n\t\tvalue = value.concat(birthDate);\r\n\t}\r\n\tif (source.gender != null) {\r\n\t\tvalue = value.concat(source.gender === \"Male\" ? \"M\" : \"F\");\r\n\t}\r\n\tif (source.HomeAddress_city) {\r\n\t\tvalue = value.concat(source.HomeAddress_city)\r\n\t}\r\n\tif (source.HomeAddress_street) {\r\n\t\tvalue = value.concat(source.HomeAddress_street.substring(0, 16));\r\n\t}\r\n\treturn deleteDiacriticalMarks(value);\r\n\treturn null;\r\n}\r\n*/\r\ngetAggregationValue()", + "validation": { + "required": false + } + }, { "name": "Contact.Business.Email", "mode": "field", "value": "Email", - "validation": { "required": false } + "validation": { + "required": false + } }, { "name": "Contact.Business.Phone.Fixed", "mode": "field", "value": "PhoneNumber", - "validation": { "required": false } + "validation": { + "required": false + } }, { "name": "ExternalId", "mode": "field", "value": "ExternalId", - "validation": { "required": false } + "validation": { + "required": false + } }, { "name": "Name.FamilyName", "mode": "field", "value": "LastName", - "validation": { "required": false } + "validation": { + "required": false + } }, { "name": "Name.NickName", "mode": "field", "value": "FirstName", - "validation": { "required": false } + "validation": { + "required": false + } }, { "name": "UserName", "mode": "field", "value": "DisplayName", - "validation": { "required": false } + "validation": { + "required": false + } } ], "contractMappings": [ + { + "name": "Custom.Intusshift", + "mode": "field", + "value": "shift.uname", + "validation": { + "required": false + }, + "convertToString": true + }, + { + "name": "Custom.PrimaryPerson", + "mode": "fixed", + "value": "2", + "validation": { + "required": false + }, + "convertToString": true + }, + { + "name": "Department.DisplayName", + "mode": "field", + "value": "group.name", + "validation": { + "required": false + } + }, + { + "name": "Department.ExternalId", + "mode": "field", + "value": "group.uname", + "validation": { + "required": false + } + }, + { + "name": "Division.Code", + "mode": "field", + "value": "group.uname", + "validation": { + "required": false + } + }, + { + "name": "Division.ExternalId", + "mode": "field", + "value": "group.uname", + "validation": { + "required": false + } + }, + { + "name": "Division.Name", + "mode": "field", + "value": "group.description", + "validation": { + "required": false + } + }, { "name": "EndDate", "mode": "field", "value": "endAt", - "validation": { "required": false } + "validation": { + "required": false + } }, { "name": "ExternalId", "mode": "field", "value": "externalId", - "validation": { "required": false } + "validation": { + "required": false + } + }, + { + "name": "Location.Code", + "mode": "field", + "value": "group.uname", + "validation": { + "required": false + } + }, + { + "name": "Location.ExternalId", + "mode": "field", + "value": "group.externalId", + "validation": { + "required": false + } }, { "name": "StartDate", "mode": "field", "value": "startAt", - "validation": { "required": false } + "validation": { + "required": false + } }, { - "name": "Location.Code", + "name": "Title.Code", "mode": "field", - "value": "group.uname", - "validation": { "required": false } + "value": "functioncode", + "validation": { + "required": false + } }, { - "name": "Location.ExternalId", + "name": "Title.ExternalId", "mode": "field", - "value": "group.externalId", - "validation": { "required": false } + "value": "functioncode", + "validation": { + "required": false + } }, { - "name": "Location.Name", + "name": "Title.Name", "mode": "field", - "value": "group.name", - "validation": { "required": false } + "value": "functionname", + "validation": { + "required": false + } } ] } From ee8658bbda62d38eee3e4abb82ffce869a1c4a9a Mon Sep 17 00:00:00 2001 From: rhouthuijzen <116062840+rhouthuijzen@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:00:53 +0100 Subject: [PATCH 5/6] Fixes after implementation --- departments.ps1 | 215 +++++++++++++++++++++ persons.ps1 | 486 ++++++++++++++++++++++++++++++------------------ readme.md | 38 ++-- 3 files changed, 534 insertions(+), 205 deletions(-) create mode 100644 departments.ps1 diff --git a/departments.ps1 b/departments.ps1 new file mode 100644 index 0000000..31d3071 --- /dev/null +++ b/departments.ps1 @@ -0,0 +1,215 @@ +################################################## +# HelloID-Conn-Prov-Source-Intus-Inplanning-Departments +# +# Version: 1.1.0 +################################################## + +# Initialize default value's +$config = $configuration | ConvertFrom-Json +$Script:expirationTimeAccessToken = $null +$Script:AuthenticationHeaders = $null +$Script:BaseUrl = $config.BaseUrl +$Script:Username = $config.Username +$Script:Password = $config.Password + +#region functions +function Resolve-IntusInplanningError { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [object] + $ErrorObject + ) + process { + $httpErrorObj = [PSCustomObject]@{ + ScriptLineNumber = $ErrorObject.InvocationInfo.ScriptLineNumber + Line = $ErrorObject.InvocationInfo.Line + ErrorDetails = $ErrorObject.Exception.Message + FriendlyMessage = $ErrorObject.Exception.Message + } + + try { + $errorMessage = (($ErrorObject.ErrorDetails.Message | ConvertFrom-Json)) + $httpErrorObj.FriendlyMessage = $errorMessage.error_description + $httpErrorObj.ErrorDetails = $errorMessage.error + } + catch { + # If the error details cannot be parsed as JSON, we keep the original message as both the error details and friendly message. + } + Write-Output $httpErrorObj + } +} + +function Retrieve-AccessToken { + [CmdletBinding()] + param() + try { + $pair = "$($Username):$($Password)" + $bytes = [System.Text.Encoding]::ASCII.GetBytes($pair) + $base64 = [System.Convert]::ToBase64String($bytes) + $tokenHeaders = @{ + 'Content-Type' = 'application/x-www-form-urlencoded' + Authorization = "Basic $base64" + } + $splatGetToken = @{ + Uri = "$($BaseUrl)/token" + Headers = $tokenHeaders + Method = 'POST' + Body = 'grant_type=client_credentials' + } + + $retryCount = 0 + $maxRetries = 5 + do { + try { + $access_token = (Invoke-RestMethod @splatGetToken) + break + } + catch { + $retryCount++ + $ex = $PSItem + $errorObj = Resolve-IntusInplanningError -ErrorObject $ex + $retryCount++ + if ($retryCount -lt $maxRetries) { + Write-Warning "Error during API call. Retry attempt [$retryCount] of [$maxRetries]. Uri [$($splatGetToken.Uri)] Error: [$($errorObj.ErrorDetails)] [$($errorObj.FriendlyMessage)]" + Start-Sleep -Seconds 5 + } + else { + throw $ex + } + } + } while ($retryCount -lt $maxRetries) + + $Script:expirationTimeAccessToken = (Get-Date).AddSeconds($access_token.expires_in) + return $access_token.access_token + } + catch { + $PSCmdlet.ThrowTerminatingError($_) + } +} + +function Confirm-AccessTokenIsValid { + [CmdletBinding()] + param() + try { + if ($null -ne $Script:expirationTimeAccessToken) { + if ((Get-Date) -le $Script:expirationTimeAccessToken) { + return $true + } + write-warning "Access token is no longer valid. Expiration time: $($Script:expirationTimeAccessToken)" + } + return $false + } + catch { + $PSCmdlet.ThrowTerminatingError($_) + } +} + +function Invoke-IntusInplanningRestMethod { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] + $Uri + ) + + try { + # Check if token is still valid + $tokenValid = Confirm-AccessTokenIsValid + if ($false -eq $tokenValid) { + Start-Sleep -Seconds 5 # Wait 5 seconds before trying to retrieve a new token. + $newAccessToken = Retrieve-AccessToken + $Script:AuthenticationHeaders = @{ + Authorization = "Bearer $($newAccessToken)" + Accept = 'application/json; charset=utf-8' + } + Start-Sleep -Seconds 5 # Wait 5 seconds after trying to retrieve a new token. + } + + # Execute with retry logic + $retryCount = 0 + $maxRetries = 5 + $result = $null + + $SplatRestMethodParameters = @{ + Uri = $Uri + Headers = $Script:AuthenticationHeaders + Method = 'GET' + } + + do { + try { + $result = Invoke-RestMethod @SplatRestMethodParameters + break + } + catch { + $ex = $PSItem + $errorObj = Resolve-IntusInplanningError -ErrorObject $ex + if ($ex.ErrorDetails.Message -like '*211 - resource: The item you\u0027re trying to edit does not exist.*') { + Write-Warning "Resource does not exist: $Uri" + break + } + $retryCount++ + if ($retryCount -lt $maxRetries) { + if (($errorObj.ErrorDetails -eq 'invalid_token') -or ($errorObj.ErrorDetails -eq 'token_expired')) { + Start-Sleep -Seconds 5 # Wait 5 seconds before trying to retrieve a new token. + Write-Warning "Access token is [$($errorObj.ErrorDetails)] [$($errorObj.FriendlyMessage)]. Attempting to retrieve a new access token. Retry attempt $retryCount of $maxRetries." + $newAccessToken = Retrieve-AccessToken + $Script:AuthenticationHeaders = @{ + Authorization = "Bearer $($newAccessToken)" + Accept = 'application/json; charset=utf-8' + } + $SplatRestMethodParameters.Headers = $Script:AuthenticationHeaders + Start-Sleep -Seconds 5 # Wait 5 seconds after trying to retrieve a new token. + } + else { + Write-Warning "Error during API call. Retry attempt [$retryCount] of [$maxRetries]. Uri [$($SplatRestMethodParameters.Uri)] Error: [$($errorObj.ErrorDetails)] [$($errorObj.FriendlyMessage)]" + } + Start-Sleep -Milliseconds 500 + } + else { + throw $ex + } + } + } while ($retryCount -lt $maxRetries) + + return $result + } + catch { + $PSCmdlet.ThrowTerminatingError($_) + } +} +#endregion functions + +try { + $actionMessage = "retrieving resource groups" + $splatGetResourceGroups = @{ + Uri = "$($Script:BaseUrl)/v2/resourcegroups" + } + $resourceGroupsResponse = Invoke-IntusInplanningRestMethod @splatGetResourceGroups + $resourceGroups = $resourceGroupsResponse | Sort-Object uname -Unique + write-information "Total number of unique resource groups retrieved: $($resourceGroups.count)." + + foreach ($resource in $resourceGroups) { + $departmentObject = [PSCustomObject]@{ + ExternalId = $resource.uname + DisplayName = $resource.name + ManagerExternalId = $null + ParentExternalId = $resource.parent + } + Write-Output $departmentObject | ConvertTo-Json -Depth 10 + } +} +catch { + $ex = $PSItem + if ($($ex.Exception.GetType().FullName -eq 'Microsoft.PowerShell.Commands.HttpResponseException') -or + $($ex.Exception.GetType().FullName -eq 'System.Net.WebException')) { + $errorObj = Resolve-IntusInplanningError -ErrorObject $ex + Write-Warning "Error while $actionMessage. Error at Line '$($errorObj.ScriptLineNumber)': $($errorObj.Line). Error: $($errorObj.ErrorDetails)" + Write-Error "Error while $actionMessage. Error: $($errorObj.FriendlyMessage)" + } + else { + Write-Warning "Error while $actionMessage. Error at Line '$($ex.InvocationInfo.ScriptLineNumber)': $($ex.InvocationInfo.Line). Error: $($ex.Exception.Message)" + Write-Error "Error while $actionMessage. Error: $($ex.Exception.Message)" + } +} \ No newline at end of file diff --git a/persons.ps1 b/persons.ps1 index cd74308..2e94a17 100644 --- a/persons.ps1 +++ b/persons.ps1 @@ -1,13 +1,23 @@ ################################################## -# HelloID-Conn-Prov-Source-Inplanning-Persons +# HelloID-Conn-Prov-Source-Intus-Inplanning-Persons # # Version: 1.1.0 ################################################## + +# Sleep is added because the department script needs to finish first (invalid token messages can occur otherwise) +Start-Sleep -Seconds 30 + # Initialize default value's $config = $configuration | ConvertFrom-Json +$useUserEndpoint = $true +$Script:expirationTimeAccessToken = $null +$Script:AuthenticationHeaders = $null +$Script:BaseUrl = $config.BaseUrl +$Script:Username = $config.Username +$Script:Password = $config.Password #region functions -function Resolve-InplanningError { +function Resolve-IntusInplanningError { [CmdletBinding()] param ( [Parameter(Mandatory)] @@ -23,240 +33,346 @@ function Resolve-InplanningError { } try { - $httpErrorObj.ErrorDetails = $ErrorObject.ErrorDetails.Message - $errorMessage = (($ErrorObject.ErrorDetails.Message | ConvertFrom-Json)).message - $httpErrorObj.FriendlyMessage = $errorMessage - } catch { - $httpErrorObj.FriendlyMessage = "Received an unexpected response. The JSON could not be converted, error: [$($_.Exception.Message)]. Original error from web service: [$($ErrorObject.Exception.Message)]" + $errorMessage = (($ErrorObject.ErrorDetails.Message | ConvertFrom-Json)) + $httpErrorObj.FriendlyMessage = $errorMessage.error_description + $httpErrorObj.ErrorDetails = $errorMessage.error + } + catch { + # If the error details cannot be parsed as JSON, we keep the original message as both the error details and friendly message. } Write-Output $httpErrorObj } } function Retrieve-AccessToken { - $pair = "$($config.Username):$($config.Password)" - $bytes = [System.Text.Encoding]::ASCII.GetBytes($pair) - $base64 = [System.Convert]::ToBase64String($bytes) + [CmdletBinding()] + param() + try { + $pair = "$($Username):$($Password)" + $bytes = [System.Text.Encoding]::ASCII.GetBytes($pair) + $base64 = [System.Convert]::ToBase64String($bytes) + $tokenHeaders = @{ + 'Content-Type' = 'application/x-www-form-urlencoded' + Authorization = "Basic $base64" + } + $splatGetToken = @{ + Uri = "$($BaseUrl)/token" + Headers = $tokenHeaders + Method = 'POST' + Body = 'grant_type=client_credentials' + } + + $retryCount = 0 + $maxRetries = 5 + do { + try { + $access_token = (Invoke-RestMethod @splatGetToken) + break + } + catch { + $retryCount++ + $ex = $PSItem + $errorObj = Resolve-IntusInplanningError -ErrorObject $ex + $retryCount++ + if ($retryCount -lt $maxRetries) { + Write-Warning "Error during API call. Retry attempt [$retryCount] of [$maxRetries]. Uri [$($splatGetToken.Uri)] Error: [$($errorObj.ErrorDetails)] [$($errorObj.FriendlyMessage)]" + Start-Sleep -Seconds 5 + } + else { + throw $ex + } + } + } while ($retryCount -lt $maxRetries) - $tokenHeaders = @{ - 'Content-Type' = 'application/x-www-form-urlencoded' - Authorization = "Basic $base64" + $Script:expirationTimeAccessToken = (Get-Date).AddSeconds($access_token.expires_in) + return $access_token.access_token + } + catch { + $PSCmdlet.ThrowTerminatingError($_) } +} - $splatGetToken = @{ - Uri = "$($config.BaseUrl)/token" - Headers = $tokenHeaders - Method = 'POST' - Body = 'grant_type=client_credentials' +function Confirm-AccessTokenIsValid { + [CmdletBinding()] + param() + try { + if ($null -ne $Script:expirationTimeAccessToken) { + if ((Get-Date) -le $Script:expirationTimeAccessToken) { + return $true + } + write-warning "Access token is no longer valid. Expiration time: $($Script:expirationTimeAccessToken)" + } + return $false + } + catch { + $PSCmdlet.ThrowTerminatingError($_) } +} + +function Invoke-IntusInplanningRestMethod { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] + $Uri + ) - $result = (Invoke-RestMethod @splatGetToken) - $script:expirationTimeAccessToken = (Get-Date).AddSeconds($result.expires_in) + try { + # Check if token is still valid + $tokenValid = Confirm-AccessTokenIsValid + if ($false -eq $tokenValid) { + Start-Sleep -Seconds 5 # Wait 5 seconds before trying to retrieve a new token. + $newAccessToken = Retrieve-AccessToken + $Script:AuthenticationHeaders = @{ + Authorization = "Bearer $($newAccessToken)" + Accept = 'application/json; charset=utf-8' + } + Start-Sleep -Seconds 5 # Wait 5 seconds after trying to retrieve a new token. + } - return $result.access_token -} + # Execute with retry logic + $retryCount = 0 + $maxRetries = 5 + $result = $null -function Confirm-AccessTokenIsValid { - if ($null -ne $Script:expirationTimeAccessToken) { - if ((Get-Date) -le $Script:expirationTimeAccessToken) { - return $true + $SplatRestMethodParameters = @{ + Uri = $Uri + Headers = $Script:AuthenticationHeaders + Method = 'GET' } + + do { + try { + $result = Invoke-RestMethod @SplatRestMethodParameters + break + } + catch { + $ex = $PSItem + $errorObj = Resolve-IntusInplanningError -ErrorObject $ex + if ($ex.ErrorDetails.Message -like '*211 - resource: The item you\u0027re trying to edit does not exist.*') { + Write-Warning "Resource does not exist: $Uri" + break + } + $retryCount++ + if ($retryCount -lt $maxRetries) { + if (($errorObj.ErrorDetails -eq 'invalid_token') -or ($errorObj.ErrorDetails -eq 'token_expired')) { + Start-Sleep -Seconds 5 # Wait 5 seconds before trying to retrieve a new token. + Write-Warning "Access token is [$($errorObj.ErrorDetails)] [$($errorObj.FriendlyMessage)]. Attempting to retrieve a new access token. Retry attempt $retryCount of $maxRetries." + $newAccessToken = Retrieve-AccessToken + $Script:AuthenticationHeaders = @{ + Authorization = "Bearer $($newAccessToken)" + Accept = 'application/json; charset=utf-8' + } + $SplatRestMethodParameters.Headers = $Script:AuthenticationHeaders + Start-Sleep -Seconds 5 # Wait 5 seconds after trying to retrieve a new token. + } + else { + Write-Warning "Error during API call. Retry attempt [$retryCount] of [$maxRetries]. Uri [$($SplatRestMethodParameters.Uri)] Error: [$($errorObj.ErrorDetails)] [$($errorObj.FriendlyMessage)]" + } + Start-Sleep -Milliseconds 500 + } + else { + throw $ex + } + } + } while ($retryCount -lt $maxRetries) + + return $result + } + catch { + $PSCmdlet.ThrowTerminatingError($_) } - return $false } #endregion functions -try { - $accessToken = Retrieve-AccessToken - $headers = @{ - Authorization = "Bearer $($accessToken)" - Accept = 'application/json; charset=utf-8' - } +try { + $actionMessage = "retrieving users" - $splatGetUsers = @{ - Uri = "$($config.BaseUrl)/users?limit=0" - Headers = $headers - Method = 'GET' + if ($useUserEndpoint -eq $true) { + $splatGetUsers = @{ + Uri = "$($Script:BaseUrl)/users?limit=0" + } + $persons = Invoke-IntusInplanningRestMethod @splatGetUsers + Write-Information "Total number of persons retrieved: $($persons.count)." + $persons = $persons | Where-Object active -eq "True" + $persons = $persons | Sort-Object resource -Unique + Write-Information "Total number of active persons retrieved: $($persons.count)." } + else { + $splatGetUsers = @{ + Uri = "$($Script:BaseUrl)/humanresources?limit=0" + } + $persons = Invoke-IntusInplanningRestMethod @splatGetUsers + Write-Information "Total number of humanresources retrieved: $($persons.count)." + $persons | Add-Member -MemberType NoteProperty -Name "resource" -Value $null -Force + $persons = $persons | ForEach-Object { $_.resource = $_.uname; $_ } + # Filter out persons where all labourHists have startDate in future or endDate in past + $persons = $persons | Where-Object { + $person = $_ + $today = Get-Date + $hasValidLabourHist = $person.labourHists | Where-Object { + $startDate = if ($_.startDate) { [DateTime]$_.startDate } else { $null } + $endDate = if ($_.endDate) { [DateTime]$_.endDate } else { $null } + + $isValid = $true + if ($startDate -and $startDate -gt $today) { $isValid = $false } + if ($null -ne $endDate -and $endDate -lt $today) { $isValid = $false } + + $isValid + } + $null -ne $hasValidLabourHist + } + $persons = $persons | Select-Object -Property uname, externalId, resource, firstName, lastName, gender, phone, email + $persons = $persons | Sort-Object uname -Unique + write-information "Total number of active humanresources retrieved: $($persons.count)." - $splatGetUsers = @{ - Uri = "$($config.BaseUrl)/users?limit=0" - Headers = $headers - Method = 'GET' - } + # Example how to use the externalId from humanresources when it is used. Part [1/2] + # foreach ($person in $persons) { + # # Custom checking if person has externalId and filtering persons without a numbers as externalId or uname + # if (-not [string]::IsNullOrEmpty($person.externalId)) { + # $person.resource = $person.externalId + # } + # } + # + } - $personsWebRequest = Invoke-WebRequest @splatGetUsers - $personsCorrected = [Text.Encoding]::UTF8.GetString([Text.Encoding]::UTF8.GetBytes($personsWebRequest.content)) - $personObjects = $personsCorrected | ConvertFrom-Json - $persons = $personObjects | Where-Object active -eq "True" - $persons = $persons | Sort-Object resource -Unique + $actionMessage = "retrieving resource groups" + $splatGetResourceGroups = @{ + Uri = "$($Script:BaseUrl)/v2/resourcegroups" + } + $resourceGroupsResponse = Invoke-IntusInplanningRestMethod @splatGetResourceGroups + $resourceGroups = $resourceGroupsResponse | Sort-Object uname -Unique + $resourceGroupsGrouped = $resourceGroups | Group-Object -Property uname -AsHashTable + write-information "Total number of unique resource groups retrieved: $($resourceGroups.count)." $today = Get-Date $startDate = $today.AddDays( - $($config.HistoricalDays)).ToString('yyyy-MM-dd') $endDate = $today.AddDays($($config.FutureDays)).ToString('yyyy-MM-dd') - - foreach ($person in $persons) { - start-sleep -Milliseconds 500 - try { - If(($person.resource.Length -gt 0) -Or ($null -ne $person.resource)){ - - # Create an empty list that will hold all shifts (contracts) + $actionMessage = "retrieving roster date for person [$($person.resource)]" + if (-not([string]::IsNullOrEmpty($person.resource))) { $contracts = [System.Collections.Generic.List[object]]::new() - # Check if token is still valid - if(-not (Confirm-AccessTokenIsValid)){ - $accessToken = Retrieve-AccessToken - - $headers = @{ - Authorization = "Bearer $($accessToken)" - Accept = 'application/json; charset=utf-8' - } - } - + #resource can contain special characters + $personResource = $([System.Web.HttpUtility]::UrlEncode($person.resource)) + # Example how to use the externalId from humanresources when it is used. Part [2/2] + # $personResource = $([System.Web.HttpUtility]::UrlEncode($person.uname)) $splatGetUsersShifts = @{ - Uri = "$($config.BaseUrl)/roster/resourceRoster?resource=$($person.resource)&startDate=$($startDate)&endDate=$($endDate)" - Headers = $headers - Method = 'GET' - TimeoutSec = 3 - } - - # Retry logic for fetching shifts - $maxRetries = 3 - $retryCount = 0 - $success = $false - - while (-not $success -and $retryCount -lt $maxRetries) { - try { - $personShifts = Invoke-RestMethod @splatGetUsersShifts - $success = $true - } catch { - $retryCount++ - if ($retryCount -lt $maxRetries) { - Write-Warning "Retrying shifts for user [$($person.username)] with resource ID [$($person.resource)]... ($retryCount/$maxRetries). Error: $($_.Exception.Message)" - Start-Sleep -Milliseconds 500 - } - } - } - - if (-not $success) { - Write-Warning "Could not fetch shifts for user [$($person.username)] with resource ID [$($person.resource)] after $maxRetries attempts. Skipping user." - continue + Uri = "$($Script:BaseUrl)/roster/resourceRoster?resource=$($personResource)&startDate=$($startDate)&endDate=$($endDate)" } - If($personshifts.count -gt 0){ - $counter = 0 - foreach ($day in $personShifts.days) { + [array]$personShifts = Invoke-IntusInplanningRestMethod @splatGetUsersShifts + + If ($personShifts.count -gt 0) { + foreach ($day in $personShifts.days) { + # Reset counter to keep external ID after each day the same + $counter = 0 - # Removes days when person has vacation - if ((-not($day.parts.count -eq 0)) -and ($null -eq $day.absence)) { + # Removes days when person has vacation + if ((-not($day.parts.count -eq 0)) -and ($null -eq $day.absence)) { - $rosterDate = $day.rosterDate - foreach ($part in $day.parts) { - $counter = ($counter + 1) - if ($part.shift.uname -like '*:*') { + $rosterDate = $day.rosterDate + + foreach ($part in $day.parts) { + $counter = $counter + 1 + $externalId = "$($person.resource)-$($rosterDate)-$($counter)" + + if ($part.shift.uname -like '*:*') { + # Define the pattern for hh:mm-hh:mm $pattern = '^\d{2}:\d{2}-\d{2}:\d{2}' - $isFormatted = $true - } else { - # Formaat: hhmm-hhmm . - $pattern = '^\d{4}-\d{4}' - $isFormatted = $false + $time = [regex]::Match($part.shift.uname, $pattern) + if ($time.Success) { + $times = $time.value -split '-' + $startTime = $times[0] + $endTime = $times[1] + } + else { + $startTime = '00:00' + $endTime = '00:00' + } } - - $time = [regex]::Match($part.shift.uname, $pattern) - - if ($time.Success) { - $times = $time.value -split '-' - - $startTimeUnformatted = $times[0] - $endTimeUnformatted = $times[1] - - #Formatteer naar HH:MM - if (-not $isFormatted) { - # Converteert "0700" naar "07:00" + else { + # Define the pattern for hhmm-hhmm + $pattern = '^\d{4}-\d{4}' + $time = [regex]::Match($part.shift.uname, $pattern) + if ($time.Success) { + $times = $time.value -split '-' + $startTimeUnformatted = $times[0] + $endTimeUnformatted = $times[1] + # Format HHMM to HH:MM $startTime = "$($startTimeUnformatted.Substring(0, 2)):$($startTimeUnformatted.Substring(2, 2))" $endTime = "$($endTimeUnformatted.Substring(0, 2)):$($endTimeUnformatted.Substring(2, 2))" - } else { - - $startTime = $startTimeUnformatted - $endTime = $endTimeUnformatted } + else { + $startTime = '00:00' + $endTime = '00:00' + } + } - } else { - $startTime = '00:00' - $endTime = '00:00' - + if ($part.prop) { + $functioncode = $part.prop.uname + $function = $part.prop.name + } + else { + $functioncode = "" + $function = "" + # break # If you want to skip shifts without a function, you can uncomment this line. } - if($part.prop){ - $functioncode = $part.prop.uname - $function = $part.prop.name - } else { - $functioncode = "" - $function = "" - } + $groupExternalId = $part.group.externalId + if (-not [string]::IsNullOrEmpty($groupExternalId)) { + $partentGroup = $resourceGroupsGrouped[$groupExternalId].parent + } + + $ShiftContract = @{ + externalId = $externalId + labourHist = $part.labourHist + labourHistGroup = $part.labourHistGroup + shift = $part.shift + group = $part.group + parentGroup = $partentGroup + functioncode = $functioncode + functionname = $function + # Add the same fields as for shift. Otherwise, the HelloID mapping will fail + # The value of both the 'startAt' and 'endAt' cannot be null. If empty, HelloID is unable + # to determine the start/end date, resulting in the contract marked as 'active'. + startAt = "$($rosterDate)T$($startTime):00Z" + endAt = "$($rosterDate)T$($endTime):00Z" + } - $ShiftContract = @{ - externalId = "$($person.resource)$($rosterDate)$($time)$($counter)$($part.group.externalId)" - labourHist = $part.labourHist - labourHistGroup = $part.labourHistGroup - shift = $part.shift - group = $part.group - functioncode = $functioncode - functionname = $function - # Add the same fields as for shift. Otherwise, the HelloID mapping will fail - # The value of both the 'startAt' and 'endAt' cannot be null. If empty, HelloID is unable - # to determine the start/end date, resulting in the contract marked as 'active'. - startAt = "$($rosterDate)T$($startTime):00Z" - endAt = "$($rosterDate)T$($endTime):00Z" + $contracts.Add($ShiftContract) } - if ( - ([string]::IsNullOrEmpty($ShiftContract.functionname) -ne $True) # Function should not be empty. - ) { - $contracts.Add($ShiftContract) - } else { - #$contracts.Add($ShiftContract) # If you need the contracts without the functionnames enable this line. - } - } } - } - if ($contracts.Count -gt 0) { - $personObj = [PSCustomObject]@{ - ExternalId = $person.resource - DisplayName = "$($person.firstName) $($person.lastName)".Trim(' ') - FirstName = $person.firstName - LastName = $person.lastName - Email = $person.email - Role = $($person.roles.role | Sort-Object | Get-Unique) - resourceGroup= $($person.roles.resourceGroup | Sort-Object | Get-Unique) - shiftGroup = $($person.roles.shiftGroup | Sort-Object | Get-Unique) - Contracts = $contracts + if ($contracts.Count -gt 0) { + $personObj = [PSCustomObject]@{ + ExternalId = $person.resource + DisplayName = "$($person.firstName) $($person.lastName)".Trim(' ') + " ($($person.resource))" + FirstName = $person.firstName + LastName = $person.lastName + Email = $person.email + Contracts = $contracts + } + Write-Output $personObj | ConvertTo-Json -Depth 10 + $count++ } - Write-Output $personObj | ConvertTo-Json -Depth 20 - }} - }} catch { - $ex = $PSItem - if ($($ex.Exception.GetType().FullName -eq 'Microsoft.PowerShell.Commands.HttpResponseException')) { - $errorObj = Resolve-InplanningError -ErrorObject $ex - Write-Verbose "Could not import Inplanning person [$($person.username)]. Error at Line '$($errorObj.ScriptLineNumber)': $($errorObj.Line). Error: $($errorObj.ErrorDetails)" - Write-Error "Could not import Inplanning person [$($person.username)]. Error: $($errorObj.FriendlyMessage)" - } else { - Write-Verbose "Could not import Inplanning person [$($person.username)]. Error at Line '$($ex.InvocationInfo.ScriptLineNumber)': $($ex.InvocationInfo.Line). Error: $($ex.Exception.Message)" - Write-Error "Could not import Inplanning person [$($person.username)]. Error: $($errorObj.FriendlyMessage)" } } } -} catch { + Write-Information "Total number of persons processed: $count." +} +catch { $ex = $PSItem - if ($($ex.Exception.GetType().FullName -eq 'Microsoft.PowerShell.Commands.HttpResponseException')) { - $errorObj = Resolve-InplanningError -ErrorObject $ex - Write-Verbose "Could not import Inplanning persons. Error at Line '$($errorObj.ScriptLineNumber)': $($errorObj.Line). Error: $($errorObj.ErrorDetails)" - Write-Error "Could not import Inplanning persons. Error: $($errorObj.FriendlyMessage)" - } else { - Write-Verbose "Could not import Inplanning persons. Error at Line '$($ex.InvocationInfo.ScriptLineNumber)': $($ex.InvocationInfo.Line). Error: $($ex.Exception.Message)" - Write-Error "Could not import Inplanning persons. Error: $($errorObj.FriendlyMessage)" + if ($($ex.Exception.GetType().FullName -eq 'Microsoft.PowerShell.Commands.HttpResponseException') -or + $($ex.Exception.GetType().FullName -eq 'System.Net.WebException')) { + $errorObj = Resolve-IntusInplanningError -ErrorObject $ex + Write-Warning "Error while $actionMessage. Error at Line '$($errorObj.ScriptLineNumber)': $($errorObj.Line). Error: $($errorObj.ErrorDetails)" + Write-Error "Error while $actionMessage. Error: $($errorObj.FriendlyMessage)" } -} + else { + Write-Warning "Error while $actionMessage. Error at Line '$($ex.InvocationInfo.ScriptLineNumber)': $($ex.InvocationInfo.Line). Error: $($ex.Exception.Message)" + Write-Error "Error while $actionMessage. Error: $($ex.Exception.Message)" + } +} \ No newline at end of file diff --git a/readme.md b/readme.md index e94e6e7..8bc0223 100644 --- a/readme.md +++ b/readme.md @@ -1,9 +1,7 @@ -# HelloID-Conn-Prov-Source-Inplanning - -[![Workflow Status](https://github.com/Tools4everBV/HelloID-Conn-Prov-Source-Inplanning/actions/workflows/createRelease.yaml/badge.svg)](https://github.com/Tools4everBV/HelloID-Conn-Prov-Source-Inplanning/actions/workflows/createRelease.yaml) -![Release](https://img.shields.io/github/v/release/Tools4everBV/HelloID-Conn-Prov-Source-Inplanning?label=Release) +# HelloID-Conn-Prov-Source-Intus-Inplanning +**Readme is work in progress** | :information_source: Information | | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -15,7 +13,7 @@ ## Table of contents -- [HelloID-Conn-Prov-Source-Inplanning](#HelloID-Conn-Prov-Source-Inplanning) +- [HelloID-Conn-Prov-Source-Intus-Inplanning](#helloid-conn-prov-source-intus-inplanning) - [Table of contents](#table-of-contents) - [Introduction](#introduction) - [Endpoints](#endpoints) @@ -28,17 +26,19 @@ ## Introduction -_HelloID-Conn-Prov-Source-Inplanning_ is a _source_ connector. The purpose of this connector is to import _humanresources_ and their _resourceRoster_. A resourceRoster represents a timetable consisting of days and parts which include work places. +_HelloID-Conn-Prov-Source-Intus-Inplanning_ is a _source_ connector. The purpose of this connector is to import _humanresources_ and their _resourceRoster_. A resourceRoster represents a timetable consisting of days and parts which include work places. ### Endpoints Currently the following endpoints are being used.. -| Endpoint | -| ---------------------------- | -| api/token | -| api/users | -| api/roster/resourceRoster | +| Endpoint | Description | +| ------------------------- | ----------------------------------------------------- | +| api/token | | +| api/users | Default endpoint to get all users | +| api/humanresources | Optional endpoint to receive employees without a user | +| api/resourcegroups | To calculate upper department | +| api/roster/resourceRoster | | - The API documentation can be found at the URLs below. Make sure to replace {customerName} with the customer's name to create a working URL. @@ -51,13 +51,13 @@ Currently the following endpoints are being used.. The following settings are required to connect to the API. -| Setting | Description | Mandatory | -| ---------- | -------------------------------------------------------------------------------------- | --------- | -| Username | The Username to connect to the API | Yes | -| Password | The Password to connect to the API | Yes | -| BaseUrl | The URL to the API | Yes | -| HistoricalDays | - The number of days in the past from which the shifts will be imported.
- Will be converted to a `[DateTime]` object containing the _current date_ __minus__ the number of days specified. | Yes | -| FutureDays | - The number of days in the future from which the shifts will be imported.
- Will be converted to a `[DateTime]` object containing the _current date_ __plus__ the number of days specified. | Yes | +| Setting | Description | Mandatory | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | +| Username | The Username to connect to the API | Yes | +| Password | The Password to connect to the API | Yes | +| BaseUrl | The URL to the API | Yes | +| HistoricalDays | - The number of days in the past from which the shifts will be imported.
- Will be converted to a `[DateTime]` object containing the _current date_ __minus__ the number of days specified. | Yes | +| FutureDays | - The number of days in the future from which the shifts will be imported.
- Will be converted to a `[DateTime]` object containing the _current date_ __plus__ the number of days specified. | Yes | ### Remarks @@ -80,8 +80,6 @@ Only persons who have active shifts in the timeframe defined by `HistoricalDays` > ℹ️ _For more information on how to configure a HelloID PowerShell connector, please refer to our [documentation](https://docs.helloid.com/hc/en-us/articles/360012557600-Configure-a-custom-PowerShell-source-system) pages_ -> ℹ️ _If you need help, feel free to ask questions on our [forum](https://forum.helloid.com/forum/helloid-connectors/provisioning/5176-helloid-provisioning-source-inplanning) - ## HelloID docs The official HelloID documentation can be found at: https://docs.helloid.com/ From 018b57a4ec7c37d108703afdad6d9419ef4a7139 Mon Sep 17 00:00:00 2001 From: rhouthuijzen <116062840+rhouthuijzen@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:21:03 +0200 Subject: [PATCH 6/6] Fixes after implementation --- .github/workflows/createRelease.yaml | 81 ++------------------------ .github/workflows/verifyChangelog.yaml | 11 ++++ CHANGELOG.md | 12 ++++ departments.ps1 | 2 +- persons.ps1 | 2 +- readme.md | 35 ++++++----- 6 files changed, 46 insertions(+), 97 deletions(-) create mode 100644 .github/workflows/verifyChangelog.yaml diff --git a/.github/workflows/createRelease.yaml b/.github/workflows/createRelease.yaml index 4582873..9578f5b 100644 --- a/.github/workflows/createRelease.yaml +++ b/.github/workflows/createRelease.yaml @@ -1,82 +1,9 @@ -############################## -# Workflow: Create Release -# Version: 0.0.2 -############################## - -name: Create Release - +name: CreateRelease on: - workflow_dispatch: - inputs: - version: - description: 'Version number (e.g., v1.0.0). Leave blank to use the latest version from CHANGELOG.md.' - required: false pull_request: - types: - - closed - -permissions: - contents: write + types: [closed] jobs: create-release: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Determine Version - id: determine_version - run: | - if [ -n "${{ github.event.inputs.version }}" ]; then - VERSION="${{ github.event.inputs.version }}" - else - if [ -f CHANGELOG.md ]; then - VERSION=$(grep -oP '^## \[\K[^]]+' CHANGELOG.md | head -n 1) - if [ -z "$VERSION" ]; then - echo "No versions found in CHANGELOG.md." - exit 1 - fi - else - echo "CHANGELOG.md not found. Cannot determine version." - exit 1 - fi - fi - [[ "$VERSION" != v* ]] && VERSION="v$VERSION" - echo "VERSION=$VERSION" >> $GITHUB_ENV - echo "VERSION_NO_V=${VERSION#v}" >> $GITHUB_ENV - - - name: Extract Release Notes from CHANGELOG.md - id: extract_notes - if: ${{ github.event.inputs.version == '' }} - run: | - if [ -f CHANGELOG.md ]; then - awk '/## \['"${VERSION_NO_V}"'\]/{flag=1; next} /## \[/{flag=0} flag' CHANGELOG.md > release_notes.txt - if [ ! -s release_notes.txt ]; then - echo "No release notes found for version ${VERSION_NO_V} in CHANGELOG.md." - exit 1 - fi - else - echo "CHANGELOG.md not found in the repository." - exit 1 - fi - echo "RELEASE_NOTES<> $GITHUB_ENV - cat release_notes.txt >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - - - name: Default Release Notes - if: ${{ github.event.inputs.version != '' }} - run: | - echo "RELEASE_NOTES=Release notes not provided for version ${VERSION}." >> $GITHUB_ENV - - - name: Debug Release Notes - run: | - echo "Extracted Release Notes:" - echo "${RELEASE_NOTES}" - - - name: Create GitHub Release - run: | - gh release create "${VERSION}" --title "${VERSION}" --notes-file release_notes.txt - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + if: github.event.pull_request.merged == true + uses: Tools4everBV/.github/.github/workflows/createRelease.yaml@main diff --git a/.github/workflows/verifyChangelog.yaml b/.github/workflows/verifyChangelog.yaml new file mode 100644 index 0000000..b4d6f77 --- /dev/null +++ b/.github/workflows/verifyChangelog.yaml @@ -0,0 +1,11 @@ +name: VerifyChangelog +on: + pull_request: + +permissions: + contents: write + pull-requests: write + +jobs: + verify-changelog: + uses: Tools4everBV/.github/.github/workflows/verifyChangelog.yaml@main diff --git a/CHANGELOG.md b/CHANGELOG.md index 53fc142..40b4ec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). +## [1.2.0] - 02-04-2026 + +### Added + +- Added department script. +- Added retries and optimization with `sleep` to prevent timeouts. +- Added endpoint to calculate upper department of the shift. + +#### Changed + +- Changed `persons.ps1` with option to still use /humanresources (configuration). + ## [1.1.0] - 12-02-2025 #### Changed diff --git a/departments.ps1 b/departments.ps1 index 31d3071..9c37014 100644 --- a/departments.ps1 +++ b/departments.ps1 @@ -1,7 +1,7 @@ ################################################## # HelloID-Conn-Prov-Source-Intus-Inplanning-Departments # -# Version: 1.1.0 +# Version: 1.2.0 ################################################## # Initialize default value's diff --git a/persons.ps1 b/persons.ps1 index 2e94a17..42204a4 100644 --- a/persons.ps1 +++ b/persons.ps1 @@ -1,7 +1,7 @@ ################################################## # HelloID-Conn-Prov-Source-Intus-Inplanning-Persons # -# Version: 1.1.0 +# Version: 1.2.0 ################################################## # Sleep is added because the department script needs to finish first (invalid token messages can occur otherwise) diff --git a/readme.md b/readme.md index 8bc0223..8dbb4f0 100644 --- a/readme.md +++ b/readme.md @@ -1,14 +1,11 @@ # HelloID-Conn-Prov-Source-Intus-Inplanning -**Readme is work in progress** - -| :information_source: Information | -| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| This repository contains the connector and configuration code only. The implementer is responsible to acquire the connection details such as username, password, certificate, etc. You might even need to sign a contract or agreement with the supplier before implementing this connector. Please contact the client's application manager to coordinate the connector requirements. | +> [!IMPORTANT] +> This repository contains the connector and configuration code only. The implementer is responsible to acquire the connection details such as username, password, certificate, etc. You might even need to sign a contract or agreement with the supplier before implementing this connector. Please contact the client's application manager to coordinate the connector requirements.

- +

## Table of contents @@ -26,24 +23,23 @@ ## Introduction -_HelloID-Conn-Prov-Source-Intus-Inplanning_ is a _source_ connector. The purpose of this connector is to import _humanresources_ and their _resourceRoster_. A resourceRoster represents a timetable consisting of days and parts which include work places. +_HelloID-Conn-Prov-Source-Intus-Inplanning_ is a _source_ connector. The purpose of this connector is to import _users_ or _humanresources_ and their _resourceRoster_. A resourceRoster represents a timetable consisting of days and parts which include work places. ### Endpoints Currently the following endpoints are being used.. -| Endpoint | Description | -| ------------------------- | ----------------------------------------------------- | -| api/token | | -| api/users | Default endpoint to get all users | -| api/humanresources | Optional endpoint to receive employees without a user | -| api/resourcegroups | To calculate upper department | -| api/roster/resourceRoster | | +| Endpoint | Method | Description | +| ------------------------- | ------ | ----------------------------------------------------- | +| api/token | POST | | +| api/users | GET | Default endpoint to get all users | +| api/humanresources | GET | Optional endpoint to receive employees without a user | +| api/resourcegroups | GET | To calculate upper department | +| api/roster/resourceRoster | GET | -- The API documentation can be found at the URLs below. Make sure to replace {customerName} with the customer's name to create a working URL. -> [Inplanning API documentation Human resources](https://{customerName}.rooster.nl/InPlanningService/openapi/#/default/getHumanResources). -> [Inplanning API documentation Resource roster](https://{customerName}.rooster.nl/InPlanningService/openapi/#/default/getResourceRoster). +- The API documentation can be found at the URL below. Make sure to replace {customerName} with the customer's name to create a working URL. + - [Inplanning API documentation](https://{customerName}.rooster.nl/InPlanningService/openapi/). ## Getting started @@ -66,9 +62,12 @@ The following settings are required to connect to the API. - We filter out absences to avoid creating contracts and granting permissions in target systems based on shifts that will not take place. - Diacritical marks do not come through correctly in HelloID when the connector is run on-premises. Therefore, it is preferable not to enable that toggle. + +- To prevent timeouts, the code uses `Start-Sleep`. Adjust the sleep intervals if performance issues occur in inPlanning. + #### Logic in-depth -The purpose of this connector is to import _humanresources_ and their _resourceRoster_. A resource roster consists of days which include parts. each part represents a shift with a start and end time. Each part will result in a contract in HelloID +The purpose of this connector is to import _users_ or _humanresources_ and their _resourceRoster_. A resource roster consists of days which include parts. each part represents a shift with a start and end time. Each part will result in a contract in HelloID All workers are imported and then the days will be imported within a specified timeframe, configured by the `HistoricalDays` and `FutureDays` settings in the configuration.