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
-
-[](https://github.com/Tools4everBV/HelloID-Conn-Prov-Source-Inplanning/actions/workflows/createRelease.yaml)
-
+# 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.