functions/New-SPCase.ps1

function New-SPCase {
    <#
    .SYNOPSIS
        Create a new eDiscovery case and add searches for the locations identified.
     
    .DESCRIPTION
        Create a new eDiscovery case and add searches for the locations identified.
        Users the user-associated site permissions returned by Invoke-SPPermissionScan or
        ConvertTo-UserSitePermission commands to dynamically generate searches.
 
        Note:
        Technically this supports advanced eDiscovery, but the search will not be accessible through the UI.
     
    .PARAMETER PermissionData
        Data generated by Invoke-SPPermissionScan or ConvertTo-UserSitePermission.
        Technically accepts any objects that contain two properties:
        - UserPrincipalName
        - Site
        With "Site" containing the sharepoint site Url.
     
    .PARAMETER Case
        Name of the case to create or use.
        Adds searches to an existing case if present.
     
    .PARAMETER Search
        Name of the search to generate.
        Searches have their case-name prepended to ensure uniqueness.
     
    .PARAMETER Type
        Whether to use basic or advanced eDiscovery.
        Defaults to basic, which is for now the recommended way to create searches.
     
    .PARAMETER CaseMode
        Create one case in total or one per user?
        Note: When selecting per user, the same site may be searched multiple times.
        Defaults to: Bulk
     
    .PARAMETER SearchMode
        Create one search in total or one per user? (or none at all?)
        Note: When selecting per user, the same site may be searched multiple times.
        Defaults to: Bulk
 
    .PARAMETER HoldMode
        Create holds for the identified sites. One in total, one per user?
        Note: When selecting per user, the same site may be put on hold multiple times.
        Multiple holds do not conflict, but ALL of them must be lifted for the site to be released.
        Defaults to: None
     
    .PARAMETER SitesPerSearch
        How many sites to add to a single search.
        If set to higher than 0, each search will have its index number appended to the name.
 
    .PARAMETER SitesPerHold
        How many sites to add to a single hold.
        Defaults to 100, cannot be larger than 100.
     
    .EXAMPLE
        PS C:\> New-SPCase -PermissionData $permissions -Case contoso_test -Search Test
 
        Creates a new case named 'contoso_test' for the permissions scanned in $permissions.
         
    .EXAMPLE
        PS C:\> New-SPCase -PermissionData (Import-Csv .\export-result.csv) -Case contoso_test -Search Test -SearchMode PerUser -SitesPerSearch 40
 
        Creates a new case named 'contoso_test'
        It uses the results of the permission scan stored in "export-result.csv" to generate searches.
        For each user, a separate set of searches will be created (which may include some site duplication).
        For each user, the total set of applicable sites will be split into sets of no more than 40 sites per search.
 
    .EXAMPLE
        PS C:\> New-SPCase -PermissionData (Import-Csv .\export-result.csv) -Case contoso_test -Search Test -SearchMode None -HoldMode Bulk
 
        Creates a new case named 'contoso_test'
        It uses the results of the permission scan stored in "export-result.csv" to generate a hold over all sites found.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        $PermissionData,

        [Parameter(Mandatory = $true)]
        [string]
        $Case,

        [string]
        $Search = 'Sharepoint',

        [ValidateSet('Advanced', 'Basic')]
        [string]
        $Type = 'Basic',

        [ValidateSet('Bulk', 'PerUser')]
        [string]
        $CaseMode = 'Bulk',

        [ValidateSet('Bulk', 'PerUser', 'None')]
        [string]
        $SearchMode = 'Bulk',

        [ValidateSet('Bulk', 'PerUser', 'None')]
        [string]
        $HoldMode = 'None',

        [int]
        $SitesPerSearch = -1,

        [ValidateRange(1,100)]
        [int]
        $SitesPerHold = 100
    )

    begin {
        #region Utility Functions
        function New-Case {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
            [CmdletBinding()]
            param (
                [Parameter(Mandatory = $true)]
                $PermissionData,

                [ValidateSet('Bulk', 'PerUser', 'None')]
                [string]
                $SearchMode,

                [ValidateSet('Bulk', 'PerUser', 'None')]
                [string]
                $HoldMode,

                [int]
                $SitesPerSearch,

                [int]
                $SitesPerHold,

                [string]
                $Search,

                [ValidateSet('Advanced', 'Basic')]
                [string]
                $Type,

                [string]
                $Name
            )

            begin {
                $typeHash = @{
                    Advanced = 'AdvancedEdiscovery'
                    Basic = 'eDiscovery'
                }
            }
            process {
                $caseObject = Get-ComplianceCase -Identity $Name -ErrorAction Ignore
                if (-not $caseObject) {
                    $caseObject = New-ComplianceCase -Name $Name -CaseType $typeHash[$Type]
                }

                switch ($SearchMode) {
                    Bulk {
                        New-Search -Name $Search -PermissionData $PermissionData -SitesPerSearch $SitesPerSearch -CaseObject $caseObject
                    }
                    PerUser {
                        foreach ($group in $PermissionData | Group-Object UserPrincipalName) {
                            New-Search -Name "$($Search)_$($group.Name -replace '@','_')" -PermissionData $group.Group -SitesPerSearch $SitesPerSearch -CaseObject $caseObject
                        }
                    }
                }

                switch ($HoldMode) {
                    Bulk {
                        New-Hold -Name $Search -PermissionData $PermissionData -CaseObject $caseObject -SitesPerHold $SitesPerHold
                    }
                    PerUser {
                        foreach ($group in $PermissionData | Group-Object UserPrincipalName) {
                            New-Hold -Name "$($Search)_$($group.Name -replace '@','_')" -PermissionData $group.Group -CaseObject $caseObject -SitesPerHold $SitesPerHold
                        }
                    }
                }
            }
        }
        function New-Hold {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
            [CmdletBinding()]
            param (
                [string]
                $Name,

                $PermissionData,

                $CaseObject,

                [int]
                $SitesPerHold
            )

            $sites = @($PermissionData.Site | Sort-Object -Unique)
            $index = 0
            $count = 0
            do {
                $policy = $null
                $count++
                $holdName = '{0}_{1}' -f $Name, $count
                Write-PSFMessage -Message 'Creating hold: {0}' -StringValues $holdName

                $length = $SitesPerHold
                if (($sites.Count - $index) -lt $length) { $length = $sites.Count - $index }
                $endIndex = $index + $length - 1
                $currentSites = $sites[$index..$endIndex]
                
                <#
                Creating a Hold Policy will fail if the sites no longer exist or are in readonly mode.
                Even if only one site in the list is affected, it will fail.
                So we parse the error for the bad site, remove it from the list and try again, until either:
                - No bad site is in the list
                - No sites remain
                - Another, unrelated error occurs
                #>

                while ($true) {
                    # Case: All sites were deleted/readonly
                    if (-not $currentSites) { break}
                    try {
                        $policy = New-CaseHoldPolicy -Name $holdName -Case $CaseObject.Identity -SharePointLocation $currentSites -ErrorAction Stop
                        break
                    }
                    catch {
                        $wasNotFound = $_ -match 'No exact match was found'
                        $wasReadOnly = $_ -match 'locked in ReadOnly mode'
                        if (-not ($wasNotFound -or $wasReadOnly)) { Stop-PSFFunction -Message 'Unexpected Error' -ErrorRecord $_ -EnableException $true -Cmdlet $PSCmdlet }
                        $siteLink = @(($_ | Select-String '(https://[^\s"]+)').Matches.Groups)[1].Value
                        if ($wasNotFound) { Write-PSFMessage -Level Warning -Message 'Site no longer exists: {0}' -StringValues $siteLink }
                        if ($wasReadOnly) { Write-PSFMessage -Level Warning -Message 'Site is read only: {0}' -StringValues $siteLink }
                        $currentSites = $currentSites | Where-Object { $_ -ne $siteLink }
                    }
                }
                if ($policy) {
                    $null = New-CaseHoldRule -Name $holdName -Policy $policy.Guid
                }
                
                
                $index = $index + $length
            }
            while ($index -lt $sites.Count)
        }
        function New-Search {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
            [CmdletBinding()]
            param (
                [string]
                $Name,

                $PermissionData,

                [int]
                $SitesPerSearch,

                $CaseObject
            )

            $sites = $PermissionData.Site | Sort-Object -Unique

            if ($SitesPerSearch -lt 1) {
                New-ComplianceSearch -Name "$($CaseObject.Name)_$($Name)" -Case $CaseObject.Name -SharePointLocation $sites
                return
            }

            $index = @{ Number = 0 }
            foreach ($set in $sites | Group-Object { $index.Number++; ('{0}' -f (($index.Number - 1) / $SitesPerSearch) -replace '\.\d+$' -as [int]) + 1 }) {
                New-ComplianceSearch -Name "$($CaseObject.Name)_$($Name)_$($set.Name)" -Case $CaseObject.Name -SharePointLocation $set.Group
            }
        }
        #endregion Utility Functions
    }
    process {
        switch ($CaseMode) {
            Bulk {
                $param = $PSBoundParameters | ConvertTo-PSFHashtable -Include PermissionData, SearchMode, SitesPerSearch, SitesPerHold, Search, Type, HoldMode -Inherit
                New-Case @param -Name $Case
            }
            PerUser {
                foreach ($group in $PermissionData | Group-Object UserPrincipalName) {
                    $param = $PSBoundParameters | ConvertTo-PSFHashtable -Include SearchMode, SitesPerSearch, SitesPerHold, Search, Type, HoldMode -Inherit
                    $param.PermissionData = $group.Group
                    New-Case @param -Name "$($Case)_$($group.Name -replace '@','_')"
                }
            }
        }
    }
}