Public/Add-NSPConditionalAccessTravel.ps1

function Add-NSPConditionalAccessTravel {
    <#
    .SYNOPSIS
        Adds a travel entry to a tenant profile: a standing country allowance, or a dated trip.
    .DESCRIPTION
        Offline; changes only the tenant profile. The next deployment plan then proposes the travel
        group (on-premises in a hybrid tenant, with a cloud-only _azure companion), any missing
        country named locations, the travel policy in report-only, and NSP-TRAVEL-SESSION-01.
 
        Country (-Country FR): group Group-Travel-France, policy NSP-TRAVEL-FR-01 'Travel: allow
        France'. Group and policy names use the country's name; the code stays the entry's key.
        One entry per country.
        Trip (-TripName, -Country, -EndDate): group Group-Travel-Trip-NN, policy NSP-TRAVEL-TRIP-NN,
        allowing every listed country (layovers included). Trip numbers are never reused. After
        the end date, the plan retires the trip.
 
        -SessionHours sets how often travelers sign in again (NSP-TRAVEL-SESSION-01). Left unset,
        it follows the module's default: 72 hours, sent to Graph as 3 days.
 
        A travel group is excluded from NSP-BASE-GEO-01 only while its travel policy is On. When
        NSP-BASE-GEO-01 is On, the travel policies are created On and the exclusion follows in the
        same run, so the traveler can sign in abroad as soon as it finishes. Keep each traveler in
        one travel group at a time: travel policies are separate blocks and stack.
    .EXAMPLE
        Add-NSPConditionalAccessTravel -TenantProfile $profilePath -Country 'CA'
    .EXAMPLE
        Add-NSPConditionalAccessTravel -TenantProfile $profilePath -TripName 'Sales Tokyo' -Country 'FR', 'JP' -EndDate '2026-10-30'
    #>

    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Country')]
    param(
        [Parameter(Mandatory)][string]$TenantProfile,
        [Parameter(Mandatory)][ValidatePattern('^[A-Za-z]{2}$')][string[]]$Country,
        [Parameter(Mandatory, ParameterSetName = 'Trip')][ValidateNotNullOrEmpty()][string]$TripName,
        [Parameter(Mandatory, ParameterSetName = 'Trip')][datetime]$EndDate,
        [ValidateRange(1, 720)][int]$SessionHours
    )

    $content = Get-Content -LiteralPath $TenantProfile -Raw | ConvertFrom-Json
    $homeCountry = [string]$content.HomeCountry
    $countries = @($Country | ForEach-Object { $_.ToUpperInvariant() } | Select-Object -Unique)
    if (-not $content.Travel) { $content | Add-Member -NotePropertyName Travel -NotePropertyValue ([pscustomobject]@{ NextTripNumber = 1; Entries = [pscustomobject]@{} }) }
    if (-not $content.Travel.Entries) { $content.Travel | Add-Member -NotePropertyName Entries -NotePropertyValue ([pscustomobject]@{}) -Force }
    if ($PSBoundParameters.ContainsKey('SessionHours')) { $content.Travel | Add-Member -NotePropertyName SessionHours -NotePropertyValue $SessionHours -Force }
    $now = (Get-Date).ToUniversalTime().ToString('o')

    $added = @(if ($PSCmdlet.ParameterSetName -eq 'Trip') {
            $stops = @($countries | Where-Object { $_ -ne $homeCountry })
            if (-not $stops.Count) { throw 'A trip needs at least one country other than the home country.' }
            if ($EndDate.Date -lt (Get-Date).Date) { throw "The end date $($EndDate.ToString('yyyy-MM-dd')) has already passed." }
            $number = [int]$content.Travel.NextTripNumber
            if ($number -lt 1) { $number = 1 }
            if ($number -gt 99) { throw 'Trip numbers are exhausted (NSP-TRAVEL-TRIP-99). Renumbering needs a design decision.' }
            $key = 'TRIP-{0:D2}' -f $number
            $content.Travel | Add-Member -NotePropertyName NextTripNumber -NotePropertyValue ($number + 1) -Force
            [pscustomobject]@{ Key = $key; Entry = [pscustomobject][ordered]@{
                    Kind = 'Trip'; Name = $TripName; Countries = $stops; EndDate = $EndDate.ToString('yyyy-MM-dd')
                    Ids = @(); Labels = @(); Missing = $true; DefaultName = 'Group-Travel-Trip-{0:D2}' -f $number; CreatedAtUtc = $now
                } }
        } else {
            foreach ($iso in $countries) {
                if ($iso -eq $homeCountry) { throw "$iso is the home country; it needs no travel entry." }
                if ($content.Travel.Entries.PSObject.Properties[$iso]) { throw "A travel entry for $iso already exists." }
                [pscustomobject]@{ Key = $iso; Entry = [pscustomobject][ordered]@{
                        Kind = 'Country'; Name = (Get-NSPConditionalAccessCountryName $iso); Countries = @($iso)
                        Ids = @(); Labels = @(); Missing = $true; DefaultName = "Group-Travel-$(Get-NSPConditionalAccessCountryName $iso -ForObjectName)"; CreatedAtUtc = $now
                    } }
            }
        })

    foreach ($new in $added) { $content.Travel.Entries | Add-Member -NotePropertyName $new.Key -NotePropertyValue $new.Entry }
    if ($PSCmdlet.ShouldProcess($TenantProfile, "Add travel entr$(if ($added.Count -eq 1) { 'y' } else { 'ies' }) $(($added | ForEach-Object Key) -join ', ')")) {
        Save-NSPConditionalAccessJson -InputObject $content -Path $TenantProfile
    }
    Get-NSPConditionalAccessTravelEntry -TenantProfile $content | Where-Object Key -in @($added | ForEach-Object Key)
}